From 35dbc16fa4f3815a9feb0f80617f755b270a31e2 Mon Sep 17 00:00:00 2001 From: Kat Date: Sat, 2 Jun 2018 11:30:50 +0000 Subject: [PATCH] Add examples --- .gitignore | 2 + LICENSE | 21 +++ domain-driven-hex/Gopkg.toml | 3 + domain-driven-hex/cmd/data/main.go | 26 +++ domain-driven-hex/cmd/server/main.go | 56 ++++++ domain-driven-hex/pkg/adding/endpoint.go | 27 +++ domain-driven-hex/pkg/adding/service.go | 34 ++++ domain-driven-hex/pkg/beers/beer.go | 27 +++ domain-driven-hex/pkg/beers/sample_beers.go | 115 ++++++++++++ domain-driven-hex/pkg/listing/endpoint.go | 61 ++++++ domain-driven-hex/pkg/listing/service.go | 43 +++++ domain-driven-hex/pkg/reviewing/endpoint.go | 37 ++++ domain-driven-hex/pkg/reviewing/service.go | 32 ++++ domain-driven-hex/pkg/reviews/review.go | 26 +++ .../pkg/reviews/sample_reviews.go | 12 ++ domain-driven-hex/storage/json.go | 158 ++++++++++++++++ domain-driven-hex/storage/json/beers.json | 1 + domain-driven-hex/storage/json/beers/1.json | 8 + domain-driven-hex/storage/json/beers/10.json | 8 + domain-driven-hex/storage/json/beers/2.json | 8 + domain-driven-hex/storage/json/beers/3.json | 8 + domain-driven-hex/storage/json/beers/4.json | 8 + domain-driven-hex/storage/json/beers/5.json | 8 + domain-driven-hex/storage/json/beers/6.json | 8 + domain-driven-hex/storage/json/beers/7.json | 8 + domain-driven-hex/storage/json/beers/8.json | 8 + domain-driven-hex/storage/json/beers/9.json | 8 + domain-driven-hex/storage/json/reviews.json | 1 + ...1)_Joe_Tribiani_%!s(int64=1510317360).json | 9 + ...)_Monica_Geller_%!s(int64=1508679660).json | 9 + ...=1)_Ross_Geller_%!s(int64=1508932980).json | 9 + ...)_Chandler_Bing_%!s(int64=1508910900).json | 9 + ...)_Phoebe_Buffay_%!s(int64=1508604300).json | 9 + ...2)_Rachel_Green_%!s(int64=1508231520).json | 9 + domain-driven-hex/storage/memory.go | 89 +++++++++ domain-driven-hex/storage/type.go | 11 ++ domain-driven/Gopkg.toml | 3 + domain-driven/adding/endpoint.go | 27 +++ domain-driven/adding/service.go | 34 ++++ domain-driven/beers/beer.go | 27 +++ domain-driven/beers/sample_beers.go | 115 ++++++++++++ domain-driven/listing/endpoint.go | 61 ++++++ domain-driven/listing/service.go | 43 +++++ domain-driven/main.go | 56 ++++++ domain-driven/reviewing/endpoint.go | 37 ++++ domain-driven/reviewing/service.go | 32 ++++ domain-driven/reviews/review.go | 26 +++ domain-driven/reviews/sample_reviews.go | 12 ++ domain-driven/storage/json.go | 156 ++++++++++++++++ domain-driven/storage/memory.go | 89 +++++++++ domain-driven/storage/type.go | 11 ++ flat/Gopkg.toml | 24 +++ flat/data.go | 132 +++++++++++++ flat/handlers.go | 91 +++++++++ flat/handlers_test.go | 109 +++++++++++ flat/main.go | 39 ++++ flat/model.go | 24 +++ flat/storage.go | 37 ++++ flat/storage_json.go | 172 +++++++++++++++++ flat/storage_json_test.go | 1 + flat/storage_mem.go | 88 +++++++++ flat/storage_mem_test.go | 42 +++++ flat/storage_test.go | 1 + layered/Gopkg.toml | 24 +++ layered/data.go | 135 ++++++++++++++ layered/handlers/beers.go | 52 ++++++ layered/handlers/beers_test.go | 109 +++++++++++ layered/handlers/reviews.go | 51 +++++ layered/handlers/reviews_test.go | 1 + layered/main.go | 39 ++++ layered/models/beer.go | 13 ++ layered/models/review.go | 14 ++ layered/models/storage.go | 44 +++++ layered/models/storage_test.go | 1 + layered/storage/json.go | 173 +++++++++++++++++ layered/storage/json_test.go | 1 + layered/storage/memory.go | 90 +++++++++ layered/storage/memory_test.go | 43 +++++ modular/Gopkg.toml | 24 +++ modular/beers/beer.go | 13 ++ modular/beers/handler.go | 52 ++++++ modular/beers/handler_test.go | 108 +++++++++++ modular/database/data.go | 136 ++++++++++++++ modular/database/database.go | 45 +++++ modular/database/database_test.go | 1 + modular/database/json.go | 174 ++++++++++++++++++ modular/database/json_test.go | 1 + modular/database/memory.go | 93 ++++++++++ modular/database/memory_test.go | 43 +++++ modular/main.go | 40 ++++ modular/reviews/handler.go | 51 +++++ modular/reviews/handler_test.go | 1 + modular/reviews/review.go | 14 ++ 93 files changed, 3991 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 domain-driven-hex/Gopkg.toml create mode 100644 domain-driven-hex/cmd/data/main.go create mode 100644 domain-driven-hex/cmd/server/main.go create mode 100644 domain-driven-hex/pkg/adding/endpoint.go create mode 100644 domain-driven-hex/pkg/adding/service.go create mode 100644 domain-driven-hex/pkg/beers/beer.go create mode 100644 domain-driven-hex/pkg/beers/sample_beers.go create mode 100644 domain-driven-hex/pkg/listing/endpoint.go create mode 100644 domain-driven-hex/pkg/listing/service.go create mode 100644 domain-driven-hex/pkg/reviewing/endpoint.go create mode 100644 domain-driven-hex/pkg/reviewing/service.go create mode 100644 domain-driven-hex/pkg/reviews/review.go create mode 100644 domain-driven-hex/pkg/reviews/sample_reviews.go create mode 100644 domain-driven-hex/storage/json.go create mode 100644 domain-driven-hex/storage/json/beers.json create mode 100644 domain-driven-hex/storage/json/beers/1.json create mode 100644 domain-driven-hex/storage/json/beers/10.json create mode 100644 domain-driven-hex/storage/json/beers/2.json create mode 100644 domain-driven-hex/storage/json/beers/3.json create mode 100644 domain-driven-hex/storage/json/beers/4.json create mode 100644 domain-driven-hex/storage/json/beers/5.json create mode 100644 domain-driven-hex/storage/json/beers/6.json create mode 100644 domain-driven-hex/storage/json/beers/7.json create mode 100644 domain-driven-hex/storage/json/beers/8.json create mode 100644 domain-driven-hex/storage/json/beers/9.json create mode 100644 domain-driven-hex/storage/json/reviews.json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=1)_Joe_Tribiani_%!s(int64=1510317360).json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=1)_Monica_Geller_%!s(int64=1508679660).json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=1)_Ross_Geller_%!s(int64=1508932980).json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=2)_Chandler_Bing_%!s(int64=1508910900).json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=2)_Phoebe_Buffay_%!s(int64=1508604300).json create mode 100644 domain-driven-hex/storage/json/reviews/%!s(int=2)_Rachel_Green_%!s(int64=1508231520).json create mode 100644 domain-driven-hex/storage/memory.go create mode 100644 domain-driven-hex/storage/type.go create mode 100644 domain-driven/Gopkg.toml create mode 100644 domain-driven/adding/endpoint.go create mode 100644 domain-driven/adding/service.go create mode 100644 domain-driven/beers/beer.go create mode 100644 domain-driven/beers/sample_beers.go create mode 100644 domain-driven/listing/endpoint.go create mode 100644 domain-driven/listing/service.go create mode 100644 domain-driven/main.go create mode 100644 domain-driven/reviewing/endpoint.go create mode 100644 domain-driven/reviewing/service.go create mode 100644 domain-driven/reviews/review.go create mode 100644 domain-driven/reviews/sample_reviews.go create mode 100644 domain-driven/storage/json.go create mode 100644 domain-driven/storage/memory.go create mode 100644 domain-driven/storage/type.go create mode 100644 flat/Gopkg.toml create mode 100644 flat/data.go create mode 100644 flat/handlers.go create mode 100644 flat/handlers_test.go create mode 100644 flat/main.go create mode 100644 flat/model.go create mode 100644 flat/storage.go create mode 100644 flat/storage_json.go create mode 100644 flat/storage_json_test.go create mode 100644 flat/storage_mem.go create mode 100644 flat/storage_mem_test.go create mode 100644 flat/storage_test.go create mode 100644 layered/Gopkg.toml create mode 100644 layered/data.go create mode 100644 layered/handlers/beers.go create mode 100644 layered/handlers/beers_test.go create mode 100644 layered/handlers/reviews.go create mode 100644 layered/handlers/reviews_test.go create mode 100644 layered/main.go create mode 100644 layered/models/beer.go create mode 100644 layered/models/review.go create mode 100644 layered/models/storage.go create mode 100644 layered/models/storage_test.go create mode 100644 layered/storage/json.go create mode 100644 layered/storage/json_test.go create mode 100644 layered/storage/memory.go create mode 100644 layered/storage/memory_test.go create mode 100644 modular/Gopkg.toml create mode 100644 modular/beers/beer.go create mode 100644 modular/beers/handler.go create mode 100644 modular/beers/handler_test.go create mode 100644 modular/database/data.go create mode 100644 modular/database/database.go create mode 100644 modular/database/database_test.go create mode 100644 modular/database/json.go create mode 100644 modular/database/json_test.go create mode 100644 modular/database/memory.go create mode 100644 modular/database/memory_test.go create mode 100644 modular/main.go create mode 100644 modular/reviews/handler.go create mode 100644 modular/reviews/handler_test.go create mode 100644 modular/reviews/review.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d72576 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebfd551 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Kat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/domain-driven-hex/Gopkg.toml b/domain-driven-hex/Gopkg.toml new file mode 100644 index 0000000..0b5ebfc --- /dev/null +++ b/domain-driven-hex/Gopkg.toml @@ -0,0 +1,3 @@ +[[constraint]] + name = "github.com/julienschmidt/httprouter" + version = "1.1.0" diff --git a/domain-driven-hex/cmd/data/main.go b/domain-driven-hex/cmd/data/main.go new file mode 100644 index 0000000..130efe2 --- /dev/null +++ b/domain-driven-hex/cmd/data/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + + "github.com/katzien/structure-examples/domain-driven/storage" + "github.com/katzien/structure-examples/domain-driven/adding" + "github.com/katzien/structure-examples/domain-driven/reviewing" +) + +func main() { + + // error handling omitted for simplicity + beersStorage, _ := storage.NewJSONBeerStorage() + reviewsStorage, _ := storage.NewJSONReviewStorage() + + // create the available services + adder := adding.NewService(beersStorage) + reviewer := reviewing.NewService(reviewsStorage) + + // add some sample data + adder.AddSampleBeers() + reviewer.AddSampleReviews() + + fmt.Println("Finished adding sample data.") +} diff --git a/domain-driven-hex/cmd/server/main.go b/domain-driven-hex/cmd/server/main.go new file mode 100644 index 0000000..9ac1e7a --- /dev/null +++ b/domain-driven-hex/cmd/server/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "github.com/katzien/structure-examples/domain-driven/storage" + "github.com/katzien/structure-examples/domain-driven/adding" + "github.com/katzien/structure-examples/domain-driven/reviewing" + "github.com/katzien/structure-examples/domain-driven/listing" +) + +func main() { + + // set up storage + storageType := storage.InMemory // this could be a flag; hardcoded here for simplicity + + var beersStorage beers.Repository + var reviewsStorage reviews.Repository + + switch storageType { + case storage.InMemory: + beersStorage = new(storage.MemoryBeerStorage) + reviewsStorage = new(storage.MemoryReviewStorage) + case storage.JSONFiles: + // error handling omitted for simplicity + beersStorage, _ = storage.NewJSONBeerStorage(); + reviewsStorage, _ = storage.NewJSONReviewStorage(); + } + + // create the available services + adder := adding.NewService(beersStorage) + reviewer := reviewing.NewService(reviewsStorage) + lister := listing.NewService(beersStorage, reviewsStorage) + + // add some sample data + adder.AddSampleBeers() + reviewer.AddSampleReviews() + + // set up the HTTP server + router := httprouter.New() + + router.GET("/beers", listing.MakeGetBeersEndpoint(lister)) + router.GET("/beers/:id", listing.MakeGetBeerEndpoint(lister)) + router.GET("/beers/:id/reviews", listing.MakeGetBeerReviewsEndpoint(lister)) + + router.POST("/beers", adding.MakeAddBeerEndpoint(adder)) + router.POST("/beers/:id/reviews", reviewing.MakeAddBeerReviewEndpoint(reviewer)) + + fmt.Println("The beer server is on tap now: http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/domain-driven-hex/pkg/adding/endpoint.go b/domain-driven-hex/pkg/adding/endpoint.go new file mode 100644 index 0000000..8b8f859 --- /dev/null +++ b/domain-driven-hex/pkg/adding/endpoint.go @@ -0,0 +1,27 @@ +package adding + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// MakeAddBeerEndpoint creates a handler for POST /beers requests +func MakeAddBeerEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + decoder := json.NewDecoder(r.Body) + + var newBeer beers.Beer + err := decoder.Decode(&newBeer) + if err != nil { + http.Error(w, "Bad beer - this will be a HTTP status code soon!", http.StatusBadRequest) + return + } + + s.AddBeer(newBeer) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer added.") + } +} diff --git a/domain-driven-hex/pkg/adding/service.go b/domain-driven-hex/pkg/adding/service.go new file mode 100644 index 0000000..5176552 --- /dev/null +++ b/domain-driven-hex/pkg/adding/service.go @@ -0,0 +1,34 @@ +package adding + +import ( + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// Service provides beer or review adding operations +type Service interface { + AddBeer(b ...beers.Beer) + AddSampleBeers() +} + +type service struct { + bR beers.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(bR beers.Repository) Service { + return &service{bR} +} + +// AddBeer adds the given beer(s) to the database +func (s *service) AddBeer(b ...beers.Beer) { + for _, beer := range b { + _ = s.bR.Add(beer) // error handling omitted for simplicity + } +} + +// AddSampleBeers adds some sample beers to the database +func (s *service) AddSampleBeers() { + for _, b := range beers.DefaultBeers { + _ = s.bR.Add(b) // error handling omitted for simplicity + } +} \ No newline at end of file diff --git a/domain-driven-hex/pkg/beers/beer.go b/domain-driven-hex/pkg/beers/beer.go new file mode 100644 index 0000000..69aa371 --- /dev/null +++ b/domain-driven-hex/pkg/beers/beer.go @@ -0,0 +1,27 @@ +package beers + +import ( + "time" + "errors" +) + +// Beer defines the properties of a reviewable beer +type Beer struct { + ID int `json:"id"` + Name string `json:"name"` + Brewery string `json:"brewery"` + Abv float32 `json:"abv"` + ShortDesc string `json:"short_description"` + Created time.Time `json:"created"` +} + +// ErrUnknown is used when a beer could not be found. +var ErrUnknown = errors.New("unknown beer") +var ErrDuplicate = errors.New("beer already exists") + +// Repository provides access to the list of beers. +type Repository interface { + GetAll() []Beer + Get(id int) (Beer, error) + Add(Beer) error +} \ No newline at end of file diff --git a/domain-driven-hex/pkg/beers/sample_beers.go b/domain-driven-hex/pkg/beers/sample_beers.go new file mode 100644 index 0000000..b387d31 --- /dev/null +++ b/domain-driven-hex/pkg/beers/sample_beers.go @@ -0,0 +1,115 @@ +package beers + +import "time" + +var DefaultBeers = []Beer{ + { + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + }, + { + ID: 2, + Name: "Oatmeal Stout", + Brewery: "Samuel Smith", + Abv: 5, + ShortDesc: "Brewed with well water (the original well at the " + + "Old Brewery, sunk in 1758, is still in use, with the hard well " + + "water being drawn from 85 feet underground); fermented in " + + "‘stone Yorkshire squares’ to create an almost opaque, " + + "wonderfully silky and smooth textured ale with a complex " + + "medium dry palate and bittersweet finish.", + Created: time.Date(2017, time.October, 24, 22, 12, 0, 0, time.UTC), + }, + { + ID: 3, + Name: "Märzen", + Brewery: "Schlenkerla", + Abv: 5, + ShortDesc: "Bamberg's speciality, a dark, bottom fermented " + + "smokebeer, brewed with Original Schlenkerla Smokemalt from " + + "the Schlenkerla maltings and tapped according to old tradition " + + "directly from the gravity-fed oakwood cask in the historical " + + "brewery tavern.", + Created: time.Date(2017, time.October, 24, 22, 17, 0, 0, time.UTC), + }, + { + ID: 4, + Name: "Duvel", + Brewery: "Duvel Moortgat", + Abv: 9, + ShortDesc: "A Duvel is still seen as the reference among strong " + + "golden ales. Its bouquet is lively and tickles the nose with an " + + "element of citrus which even tends towards grapefruit thanks to " + + "the use of only the highest-quality hop varieties.", + Created: time.Date(2017, time.October, 24, 22, 24, 0, 0, time.UTC), + }, + { + ID: 5, + Name: "Negra", + Brewery: "Modelo", + Abv: 5, + ShortDesc: "Brewed longer to enhance the flavors, this Munich " + + "Dunkel-style Lager gives way to a rich flavor and remarkably " + + "smooth taste.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + { + ID: 6, + Name: "Guinness Draught", + Brewery: "Guinness Ltd.", + Abv: 4, + ShortDesc: "Pours dark brown, almost black with solid lasting light brown head. " + + "Aroma of bitter cocoa, light coffee and roasted malt. " + + "Body is light sweet, medium bitter. " + + "Body is light to medium, texture almost thin and carbonation average. " + + "Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + { + ID: 7, + Name: "XX Lager", + Brewery: "Cuahutemoc Moctezuma", + Abv: 4.2, + ShortDesc: "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. " + + "A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. " + + "A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + Created: time.Date(2017, time.October, 28, 15, 02, 0, 0, time.UTC), + }, + { + ID: 8, + Name: "Tecate", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. " + + "Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. " + + "Finishes a bit dry with husk tannins and a pasty mouthfeel.", + Created: time.Date(2017, time.October, 28, 15, 07, 0, 0, time.UTC), + }, + { + ID: 9, + Name: "Sol", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. " + + "You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, " + + "but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + Created: time.Date(2017, time.October, 28, 15, 12, 0, 0, time.UTC), + }, + { + ID: 10, + Name: "Corona", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "One of the five best-selling beers in the world, but it usually tastes better in Mexico, " + + "where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) " + + "This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + Created: time.Date(2017, time.October, 28, 15, 14, 0, 0, time.UTC), + }, +} diff --git a/domain-driven-hex/pkg/listing/endpoint.go b/domain-driven-hex/pkg/listing/endpoint.go new file mode 100644 index 0000000..c757439 --- /dev/null +++ b/domain-driven-hex/pkg/listing/endpoint.go @@ -0,0 +1,61 @@ +package listing + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "strconv" + "fmt" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +type Handler func(http.ResponseWriter, *http.Request, httprouter.Params) + +// MakeAddBeerEndpoint creates a handler for GET /beers requests +func MakeGetBeersEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + list := s.GetBeers() + json.NewEncoder(w).Encode(list) + } +} + +// MakeAddBeeEndpoint creates a handler for GET /beers/:id requests +func MakeGetBeerEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + beer, err := s.GetBeer(ID) + if err == beers.ErrUnknown { + http.Error(w, "The beer you requested does not exist.", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(beer) + } +} + +// MakeGetBeerReviewsEndpoint creates a handler for GET /beers/:id/reviews requests +func MakeGetBeerReviewsEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + reviews, err := s.GetBeerReviews(ID) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID.", p.ByName("id")), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(reviews) + } +} diff --git a/domain-driven-hex/pkg/listing/service.go b/domain-driven-hex/pkg/listing/service.go new file mode 100644 index 0000000..57a9915 --- /dev/null +++ b/domain-driven-hex/pkg/listing/service.go @@ -0,0 +1,43 @@ +package listing + +import ( + "github.com/katzien/structure-examples/domain-driven/reviews" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// Service provides beer or review adding operations +type Service interface { + GetBeers() []beers.Beer + GetBeer(int) (beers.Beer, error) + GetBeerReviews(int) ([]reviews.Review, error) +} + +type service struct { + bR beers.Repository + rR reviews.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(bR beers.Repository, rR reviews.Repository) Service { + return &service{bR, rR} +} + +// GetBeers returns all beers +func (s *service) GetBeers() []beers.Beer { + return s.bR.GetAll() +} + +// GetBeer returns a beer +func (s *service) GetBeer(id int) (beers.Beer, error) { + return s.bR.Get(id) +} + +// GetBeerReviews returns all requests for a beer +func (s *service) GetBeerReviews(beerID int) ([]reviews.Review, error) { + var list []reviews.Review + if _, err := s.bR.Get(beerID); err == beers.ErrUnknown { + return list, reviews.ErrNotFound + } + + return s.rR.GetAll(beerID), nil +} \ No newline at end of file diff --git a/domain-driven-hex/pkg/reviewing/endpoint.go b/domain-driven-hex/pkg/reviewing/endpoint.go new file mode 100644 index 0000000..49795ca --- /dev/null +++ b/domain-driven-hex/pkg/reviewing/endpoint.go @@ -0,0 +1,37 @@ +package reviewing + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "strconv" + "fmt" + "github.com/katzien/structure-examples/domain-driven/reviews" +) + +type Handler func(http.ResponseWriter, *http.Request, httprouter.Params) + +// MakeAddBeerEndpoint creates a handler for POST /beers/:id/reviews requests +func MakeAddBeerReviewEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + var newReview reviews.Review + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&newReview); err != nil { + http.Error(w, "Failed to parse review", http.StatusBadRequest) + } + + newReview.BeerID = ID + + s.AddBeerReview(newReview) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer review added.") + } +} diff --git a/domain-driven-hex/pkg/reviewing/service.go b/domain-driven-hex/pkg/reviewing/service.go new file mode 100644 index 0000000..9e2e5a2 --- /dev/null +++ b/domain-driven-hex/pkg/reviewing/service.go @@ -0,0 +1,32 @@ +package reviewing + +import ( + "github.com/katzien/structure-examples/domain-driven/reviews" +) + +// Service provides beer or review adding operations +type Service interface { + AddBeerReview(r reviews.Review) + AddSampleReviews() +} + +type service struct { + rR reviews.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(rR reviews.Repository) Service { + return &service{rR} +} + +// AddBeerReview saves a new beer review in the database +func (s *service) AddBeerReview(r reviews.Review) { + _ = s.rR.Add(r) // error handling omitted for simplicity +} + +// AddSampleReviews adds some sample reviews to the database +func (s *service) AddSampleReviews() { + for _, b := range reviews.DefaultReviews { + _ = s.rR.Add(b) // error handling omitted for simplicity + } +} \ No newline at end of file diff --git a/domain-driven-hex/pkg/reviews/review.go b/domain-driven-hex/pkg/reviews/review.go new file mode 100644 index 0000000..558c00f --- /dev/null +++ b/domain-driven-hex/pkg/reviews/review.go @@ -0,0 +1,26 @@ +package reviews + +import ( + "time" + "errors" +) + +// Review defines a beer review +type Review struct { + ID string `json:"id"` + BeerID int `json:"beer_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Score int `json:"score"` + Text string `json:"text"` + Created time.Time `json:"created"` +} + +// ErrNotFound is used when a beer could not be found. +var ErrNotFound = errors.New("beer not found") + +// Repository provides access to the reviews. +type Repository interface { + GetAll(beerID int) []Review + Add(review Review) error +} diff --git a/domain-driven-hex/pkg/reviews/sample_reviews.go b/domain-driven-hex/pkg/reviews/sample_reviews.go new file mode 100644 index 0000000..8556a71 --- /dev/null +++ b/domain-driven-hex/pkg/reviews/sample_reviews.go @@ -0,0 +1,12 @@ +package reviews + +import "time" + +var DefaultReviews = []Review{ + {ID: "S_1", BeerID: 1, FirstName: "Joe", LastName: "Tribiani", Score: 5, Text: "This is good but this is not pizza!", Created: time.Date(2017, time.November, 10, 12, 36, 0, 0, time.UTC)}, + {ID: "S_2", BeerID: 2, FirstName: "Chandler", LastName: "Bing", Score: 1, Text: "I would SO NOT drink this ever again.", Created: time.Date(2017, time.October, 25, 5, 55, 0, 0, time.UTC)}, + {ID: "S_3", BeerID: 1, FirstName: "Ross", LastName: "Geller", Score: 4, Text: "Drank while on a break, was pretty good!", Created: time.Date(2017, time.October, 25, 12, 3, 0, 0, time.UTC)}, + {ID: "S_4", BeerID: 2, FirstName: "Phoebe", LastName: "Buffay", Score: 2, Text: "Wasn't that great, so I gave it to my smelly cat.", Created: time.Date(2017, time.October, 21, 16, 45, 0, 0, time.UTC)}, + {ID: "S_5", BeerID: 1, FirstName: "Monica", LastName: "Geller", Score: 5, Text: "AMAZING! Like Chandler's jokes!", Created: time.Date(2017, time.October, 22, 13, 41, 0, 0, time.UTC)}, + {ID: "S_6", BeerID: 2, FirstName: "Rachel", LastName: "Green", Score: 5, Text: "So yummy, just like my beef and custard trifle.", Created: time.Date(2017, time.October, 17, 9, 12, 0, 0, time.UTC)}, +} diff --git a/domain-driven-hex/storage/json.go b/domain-driven-hex/storage/json.go new file mode 100644 index 0000000..f6dd435 --- /dev/null +++ b/domain-driven-hex/storage/json.go @@ -0,0 +1,158 @@ +package storage + +import ( + "encoding/json" + "fmt" + + "github.com/nanobox-io/golang-scribble" + "strconv" + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "time" +) + +const ( + // location defines where the files are stored + location = "./data/" + + // CollectionBeer identifier for the JSON collection of beers + CollectionBeer = "beers" + // CollectionReview identifier for the JSON collection of reviews + CollectionReview = "reviews" +) + +// JSONBeerStorage stores beer data in JSON files +type JSONBeerStorage struct { + db *scribble.Driver +} + +// NewJSONBeerStorage returns a new JSON beer storage +func NewJSONBeerStorage() (*JSONBeerStorage, error) { + var err error + + s := new(JSONBeerStorage) + + s.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return s, nil +} + +// Add saves the given beer to the repository +func (s *JSONBeerStorage) Add(b beers.Beer) error { + var resource = strconv.Itoa(b.ID) + + existingBeers := s.GetAll() + for _, e := range existingBeers { + if b.Abv == e.Abv && + b.Brewery == e.Brewery && + b.Name == e.Name { + return beers.ErrDuplicate + } + } + + b.ID = len(existingBeers) + 1 + b.Created = time.Now() + + if err := s.db.Write(CollectionBeer, resource, b); err != nil { + return err + } + return nil +} + +// Get returns a beer with the specified ID +func (s *JSONBeerStorage) Get(id int) (beers.Beer, error) { + var beer beers.Beer + var resource = strconv.Itoa(id) + + if err := s.db.Read(CollectionBeer, resource, &beer); err != nil { + return beer, beers.ErrUnknown + } + + return beer, nil +} + +// GetAll returns all beers +func (s *JSONBeerStorage) GetAll() []beers.Beer { + var list []beers.Beer + + records, err := s.db.ReadAll(CollectionBeer) + if err != nil { + return list + // panic("error while fetching beers from the JSON file storage: " + err.Error()) + } + + for _, b := range records { + var beer beers.Beer + + if err := json.Unmarshal([]byte(b), &beer); err != nil { + return list + } + + list = append(list, beer) + } + + return list +} + +// JSONReviewStorage stores review data in JSON files +type JSONReviewStorage struct { + db *scribble.Driver +} + +// NewJSONReviewStorage returns a new JSON reviews storage +func NewJSONReviewStorage() (*JSONReviewStorage, error) { + var err error + + s := new(JSONReviewStorage) + + s.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return s, nil +} + +// Add saves the given review in the repository +func (s *JSONReviewStorage) Add(r reviews.Review) error { + + var beer beers.Beer + if err := s.db.Read(CollectionBeer, strconv.Itoa(r.BeerID), &beer); err != nil { + return reviews.ErrNotFound + } + + r.ID = fmt.Sprintf("%s_%s_%s_%s", r.BeerID, r.FirstName, r.LastName, r.Created.Unix()) + r.Created = time.Now() + + if err := s.db.Write(CollectionReview, r.ID, r); err != nil { + return err + } + + return nil +} + +// GetAll returns all reviews for a given beer +func (s *JSONReviewStorage) GetAll(beerID int) []reviews.Review { + var list []reviews.Review + + records, err := s.db.ReadAll(CollectionReview) + if err != nil { + return list + //panic("error while fetching reviews from the JSON file storage: " + err.Error()) + } + + for _, r := range records { + var review reviews.Review + + if err := json.Unmarshal([]byte(r), &review); err != nil { + return list + } + + list = append(list, review) + } + + return list +} diff --git a/domain-driven-hex/storage/json/beers.json b/domain-driven-hex/storage/json/beers.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/domain-driven-hex/storage/json/beers.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/1.json b/domain-driven-hex/storage/json/beers/1.json new file mode 100644 index 0000000..f73d5c5 --- /dev/null +++ b/domain-driven-hex/storage/json/beers/1.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "name": "Pliny the Elder", + "brewery": "Russian River Brewing Company", + "abv": 8, + "short_description": "Pliny the Elder is brewed with Amarillo, Centennial, CTZ, and Simcoe hops. It is well-balanced with malt, hops, and alcohol, slightly bitter with a fresh hop aroma of floral, citrus, and pine.", + "created": "2018-06-02T04:23:46.691868217Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/10.json b/domain-driven-hex/storage/json/beers/10.json new file mode 100644 index 0000000..9f36d67 --- /dev/null +++ b/domain-driven-hex/storage/json/beers/10.json @@ -0,0 +1,8 @@ +{ + "id": 10, + "name": "Corona", + "brewery": "Cuahutemoc Moctezuma", + "abv": 5, + "short_description": "One of the five best-selling beers in the world, but it usually tastes better in Mexico, where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + "created": "2018-06-02T04:23:46.696667484Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/2.json b/domain-driven-hex/storage/json/beers/2.json new file mode 100644 index 0000000..5407f84 --- /dev/null +++ b/domain-driven-hex/storage/json/beers/2.json @@ -0,0 +1,8 @@ +{ + "id": 2, + "name": "Oatmeal Stout", + "brewery": "Samuel Smith", + "abv": 5, + "short_description": "Brewed with well water (the original well at the Old Brewery, sunk in 1758, is still in use, with the hard well water being drawn from 85 feet underground); fermented in ‘stone Yorkshire squares’ to create an almost opaque, wonderfully silky and smooth textured ale with a complex medium dry palate and bittersweet finish.", + "created": "2018-06-02T04:23:46.692730372Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/3.json b/domain-driven-hex/storage/json/beers/3.json new file mode 100644 index 0000000..0cd5e6e --- /dev/null +++ b/domain-driven-hex/storage/json/beers/3.json @@ -0,0 +1,8 @@ +{ + "id": 3, + "name": "Märzen", + "brewery": "Schlenkerla", + "abv": 5, + "short_description": "Bamberg's speciality, a dark, bottom fermented smokebeer, brewed with Original Schlenkerla Smokemalt from the Schlenkerla maltings and tapped according to old tradition directly from the gravity-fed oakwood cask in the historical brewery tavern.", + "created": "2018-06-02T04:23:46.69311576Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/4.json b/domain-driven-hex/storage/json/beers/4.json new file mode 100644 index 0000000..3863b9d --- /dev/null +++ b/domain-driven-hex/storage/json/beers/4.json @@ -0,0 +1,8 @@ +{ + "id": 4, + "name": "Duvel", + "brewery": "Duvel Moortgat", + "abv": 9, + "short_description": "A Duvel is still seen as the reference among strong golden ales. Its bouquet is lively and tickles the nose with an element of citrus which even tends towards grapefruit thanks to the use of only the highest-quality hop varieties.", + "created": "2018-06-02T04:23:46.693487474Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/5.json b/domain-driven-hex/storage/json/beers/5.json new file mode 100644 index 0000000..527a85a --- /dev/null +++ b/domain-driven-hex/storage/json/beers/5.json @@ -0,0 +1,8 @@ +{ + "id": 5, + "name": "Negra", + "brewery": "Modelo", + "abv": 5, + "short_description": "Brewed longer to enhance the flavors, this Munich Dunkel-style Lager gives way to a rich flavor and remarkably smooth taste.", + "created": "2018-06-02T04:23:46.693902212Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/6.json b/domain-driven-hex/storage/json/beers/6.json new file mode 100644 index 0000000..62725a3 --- /dev/null +++ b/domain-driven-hex/storage/json/beers/6.json @@ -0,0 +1,8 @@ +{ + "id": 6, + "name": "Guinness Draught", + "brewery": "Guinness Ltd.", + "abv": 4, + "short_description": "Pours dark brown, almost black with solid lasting light brown head. Aroma of bitter cocoa, light coffee and roasted malt. Body is light sweet, medium bitter. Body is light to medium, texture almost thin and carbonation average. Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + "created": "2018-06-02T04:23:46.69431778Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/7.json b/domain-driven-hex/storage/json/beers/7.json new file mode 100644 index 0000000..73b732c --- /dev/null +++ b/domain-driven-hex/storage/json/beers/7.json @@ -0,0 +1,8 @@ +{ + "id": 7, + "name": "XX Lager", + "brewery": "Cuahutemoc Moctezuma", + "abv": 4.2, + "short_description": "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + "created": "2018-06-02T04:23:46.695019224Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/8.json b/domain-driven-hex/storage/json/beers/8.json new file mode 100644 index 0000000..8c91fe1 --- /dev/null +++ b/domain-driven-hex/storage/json/beers/8.json @@ -0,0 +1,8 @@ +{ + "id": 8, + "name": "Tecate", + "brewery": "Cuahutemoc Moctezuma", + "abv": 5, + "short_description": "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. Finishes a bit dry with husk tannins and a pasty mouthfeel.", + "created": "2018-06-02T04:23:46.695561863Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/beers/9.json b/domain-driven-hex/storage/json/beers/9.json new file mode 100644 index 0000000..fb7f22a --- /dev/null +++ b/domain-driven-hex/storage/json/beers/9.json @@ -0,0 +1,8 @@ +{ + "id": 9, + "name": "Sol", + "brewery": "Cuahutemoc Moctezuma", + "abv": 5, + "short_description": "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + "created": "2018-06-02T04:23:46.696088568Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews.json b/domain-driven-hex/storage/json/reviews.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/domain-driven-hex/storage/json/reviews.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=1)_Joe_Tribiani_%!s(int64=1510317360).json b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Joe_Tribiani_%!s(int64=1510317360).json new file mode 100644 index 0000000..1b0d845 --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Joe_Tribiani_%!s(int64=1510317360).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=1)_Joe_Tribiani_%!s(int64=1510317360)", + "beer_id": 1, + "first_name": "Joe", + "last_name": "Tribiani", + "score": 5, + "text": "This is good but this is not pizza!", + "created": "2018-06-02T04:23:46.696981366Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=1)_Monica_Geller_%!s(int64=1508679660).json b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Monica_Geller_%!s(int64=1508679660).json new file mode 100644 index 0000000..0899c8b --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Monica_Geller_%!s(int64=1508679660).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=1)_Monica_Geller_%!s(int64=1508679660)", + "beer_id": 1, + "first_name": "Monica", + "last_name": "Geller", + "score": 5, + "text": "AMAZING! Like Chandler's jokes!", + "created": "2018-06-02T04:23:46.698357212Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=1)_Ross_Geller_%!s(int64=1508932980).json b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Ross_Geller_%!s(int64=1508932980).json new file mode 100644 index 0000000..2d284c4 --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=1)_Ross_Geller_%!s(int64=1508932980).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=1)_Ross_Geller_%!s(int64=1508932980)", + "beer_id": 1, + "first_name": "Ross", + "last_name": "Geller", + "score": 4, + "text": "Drank while on a break, was pretty good!", + "created": "2018-06-02T04:23:46.697775656Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=2)_Chandler_Bing_%!s(int64=1508910900).json b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Chandler_Bing_%!s(int64=1508910900).json new file mode 100644 index 0000000..065257c --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Chandler_Bing_%!s(int64=1508910900).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=2)_Chandler_Bing_%!s(int64=1508910900)", + "beer_id": 2, + "first_name": "Chandler", + "last_name": "Bing", + "score": 1, + "text": "I would SO NOT drink this ever again.", + "created": "2018-06-02T04:23:46.697427983Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=2)_Phoebe_Buffay_%!s(int64=1508604300).json b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Phoebe_Buffay_%!s(int64=1508604300).json new file mode 100644 index 0000000..8fd2e00 --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Phoebe_Buffay_%!s(int64=1508604300).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=2)_Phoebe_Buffay_%!s(int64=1508604300)", + "beer_id": 2, + "first_name": "Phoebe", + "last_name": "Buffay", + "score": 2, + "text": "Wasn't that great, so I gave it to my smelly cat.", + "created": "2018-06-02T04:23:46.698066795Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/json/reviews/%!s(int=2)_Rachel_Green_%!s(int64=1508231520).json b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Rachel_Green_%!s(int64=1508231520).json new file mode 100644 index 0000000..8d03101 --- /dev/null +++ b/domain-driven-hex/storage/json/reviews/%!s(int=2)_Rachel_Green_%!s(int64=1508231520).json @@ -0,0 +1,9 @@ +{ + "id": "%!s(int=2)_Rachel_Green_%!s(int64=1508231520)", + "beer_id": 2, + "first_name": "Rachel", + "last_name": "Green", + "score": 5, + "text": "So yummy, just like my beef and custard trifle.", + "created": "2018-06-02T04:23:46.698667352Z" +} \ No newline at end of file diff --git a/domain-driven-hex/storage/memory.go b/domain-driven-hex/storage/memory.go new file mode 100644 index 0000000..a6d5615 --- /dev/null +++ b/domain-driven-hex/storage/memory.go @@ -0,0 +1,89 @@ +package storage + +import ( + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "time" + "fmt" +) + +// Memory storage keeps beer data in memory +type MemoryBeerStorage struct { + beers []beers.Beer + reviews []reviews.Review +} + +// Add saves the given beer to the repository +func (m *MemoryBeerStorage) Add(b beers.Beer) error { + for _, e := range m.beers { + if b.Abv == e.Abv && + b.Brewery == e.Brewery && + b.Name == e.Name { + return beers.ErrDuplicate + } + } + + b.ID = len(m.beers) + 1 + b.Created = time.Now() + m.beers = append(m.beers, b) + + return nil +} + +// Get returns a beer with the specified ID +func (m *MemoryBeerStorage) Get(id int) (beers.Beer, error) { + var beer beers.Beer + + for i := range m.beers { + + if m.beers[i].ID == id { + return m.beers[i], nil + } + } + + return beer, beers.ErrUnknown +} + +// GetAll return all beers +func (m *MemoryBeerStorage) GetAll() []beers.Beer { + return m.beers +} + +// Memory storage keeps review data in memory +type MemoryReviewStorage struct { + beers []beers.Beer + reviews []reviews.Review +} + +// Add saves the given review in the repository +func (m *MemoryReviewStorage) Add(r reviews.Review) error { + found := false + for b := range m.beers { + if m.beers[b].ID == r.BeerID { + found = true + } + } + + if found { + r.ID = fmt.Sprintf("%s_%s_%s_%s", r.BeerID, r.FirstName, r.LastName, r.Created.Unix()) + r.Created = time.Now() + m.reviews = append(m.reviews, r) + } else { + return reviews.ErrNotFound + } + + return nil +} + +// GetAll returns all reviews for a given beer +func (m *MemoryReviewStorage) GetAll(beerID int) []reviews.Review { + var list []reviews.Review + + for i := range m.reviews { + if m.reviews[i].BeerID == beerID { + list = append(list, m.reviews[i]) + } + } + + return list +} diff --git a/domain-driven-hex/storage/type.go b/domain-driven-hex/storage/type.go new file mode 100644 index 0000000..d1f0597 --- /dev/null +++ b/domain-driven-hex/storage/type.go @@ -0,0 +1,11 @@ +package storage + +// StorageType defines available storage types +type Type int + +const ( + // JSON will store data in JSON files saved on disk + JSONFiles Type = iota + // Memory will store data in memory + InMemory +) \ No newline at end of file diff --git a/domain-driven/Gopkg.toml b/domain-driven/Gopkg.toml new file mode 100644 index 0000000..0b5ebfc --- /dev/null +++ b/domain-driven/Gopkg.toml @@ -0,0 +1,3 @@ +[[constraint]] + name = "github.com/julienschmidt/httprouter" + version = "1.1.0" diff --git a/domain-driven/adding/endpoint.go b/domain-driven/adding/endpoint.go new file mode 100644 index 0000000..8b8f859 --- /dev/null +++ b/domain-driven/adding/endpoint.go @@ -0,0 +1,27 @@ +package adding + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// MakeAddBeerEndpoint creates a handler for POST /beers requests +func MakeAddBeerEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + decoder := json.NewDecoder(r.Body) + + var newBeer beers.Beer + err := decoder.Decode(&newBeer) + if err != nil { + http.Error(w, "Bad beer - this will be a HTTP status code soon!", http.StatusBadRequest) + return + } + + s.AddBeer(newBeer) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer added.") + } +} diff --git a/domain-driven/adding/service.go b/domain-driven/adding/service.go new file mode 100644 index 0000000..5176552 --- /dev/null +++ b/domain-driven/adding/service.go @@ -0,0 +1,34 @@ +package adding + +import ( + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// Service provides beer or review adding operations +type Service interface { + AddBeer(b ...beers.Beer) + AddSampleBeers() +} + +type service struct { + bR beers.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(bR beers.Repository) Service { + return &service{bR} +} + +// AddBeer adds the given beer(s) to the database +func (s *service) AddBeer(b ...beers.Beer) { + for _, beer := range b { + _ = s.bR.Add(beer) // error handling omitted for simplicity + } +} + +// AddSampleBeers adds some sample beers to the database +func (s *service) AddSampleBeers() { + for _, b := range beers.DefaultBeers { + _ = s.bR.Add(b) // error handling omitted for simplicity + } +} \ No newline at end of file diff --git a/domain-driven/beers/beer.go b/domain-driven/beers/beer.go new file mode 100644 index 0000000..69aa371 --- /dev/null +++ b/domain-driven/beers/beer.go @@ -0,0 +1,27 @@ +package beers + +import ( + "time" + "errors" +) + +// Beer defines the properties of a reviewable beer +type Beer struct { + ID int `json:"id"` + Name string `json:"name"` + Brewery string `json:"brewery"` + Abv float32 `json:"abv"` + ShortDesc string `json:"short_description"` + Created time.Time `json:"created"` +} + +// ErrUnknown is used when a beer could not be found. +var ErrUnknown = errors.New("unknown beer") +var ErrDuplicate = errors.New("beer already exists") + +// Repository provides access to the list of beers. +type Repository interface { + GetAll() []Beer + Get(id int) (Beer, error) + Add(Beer) error +} \ No newline at end of file diff --git a/domain-driven/beers/sample_beers.go b/domain-driven/beers/sample_beers.go new file mode 100644 index 0000000..b387d31 --- /dev/null +++ b/domain-driven/beers/sample_beers.go @@ -0,0 +1,115 @@ +package beers + +import "time" + +var DefaultBeers = []Beer{ + { + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + }, + { + ID: 2, + Name: "Oatmeal Stout", + Brewery: "Samuel Smith", + Abv: 5, + ShortDesc: "Brewed with well water (the original well at the " + + "Old Brewery, sunk in 1758, is still in use, with the hard well " + + "water being drawn from 85 feet underground); fermented in " + + "‘stone Yorkshire squares’ to create an almost opaque, " + + "wonderfully silky and smooth textured ale with a complex " + + "medium dry palate and bittersweet finish.", + Created: time.Date(2017, time.October, 24, 22, 12, 0, 0, time.UTC), + }, + { + ID: 3, + Name: "Märzen", + Brewery: "Schlenkerla", + Abv: 5, + ShortDesc: "Bamberg's speciality, a dark, bottom fermented " + + "smokebeer, brewed with Original Schlenkerla Smokemalt from " + + "the Schlenkerla maltings and tapped according to old tradition " + + "directly from the gravity-fed oakwood cask in the historical " + + "brewery tavern.", + Created: time.Date(2017, time.October, 24, 22, 17, 0, 0, time.UTC), + }, + { + ID: 4, + Name: "Duvel", + Brewery: "Duvel Moortgat", + Abv: 9, + ShortDesc: "A Duvel is still seen as the reference among strong " + + "golden ales. Its bouquet is lively and tickles the nose with an " + + "element of citrus which even tends towards grapefruit thanks to " + + "the use of only the highest-quality hop varieties.", + Created: time.Date(2017, time.October, 24, 22, 24, 0, 0, time.UTC), + }, + { + ID: 5, + Name: "Negra", + Brewery: "Modelo", + Abv: 5, + ShortDesc: "Brewed longer to enhance the flavors, this Munich " + + "Dunkel-style Lager gives way to a rich flavor and remarkably " + + "smooth taste.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + { + ID: 6, + Name: "Guinness Draught", + Brewery: "Guinness Ltd.", + Abv: 4, + ShortDesc: "Pours dark brown, almost black with solid lasting light brown head. " + + "Aroma of bitter cocoa, light coffee and roasted malt. " + + "Body is light sweet, medium bitter. " + + "Body is light to medium, texture almost thin and carbonation average. " + + "Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + { + ID: 7, + Name: "XX Lager", + Brewery: "Cuahutemoc Moctezuma", + Abv: 4.2, + ShortDesc: "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. " + + "A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. " + + "A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + Created: time.Date(2017, time.October, 28, 15, 02, 0, 0, time.UTC), + }, + { + ID: 8, + Name: "Tecate", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. " + + "Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. " + + "Finishes a bit dry with husk tannins and a pasty mouthfeel.", + Created: time.Date(2017, time.October, 28, 15, 07, 0, 0, time.UTC), + }, + { + ID: 9, + Name: "Sol", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. " + + "You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, " + + "but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + Created: time.Date(2017, time.October, 28, 15, 12, 0, 0, time.UTC), + }, + { + ID: 10, + Name: "Corona", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "One of the five best-selling beers in the world, but it usually tastes better in Mexico, " + + "where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) " + + "This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + Created: time.Date(2017, time.October, 28, 15, 14, 0, 0, time.UTC), + }, +} diff --git a/domain-driven/listing/endpoint.go b/domain-driven/listing/endpoint.go new file mode 100644 index 0000000..c757439 --- /dev/null +++ b/domain-driven/listing/endpoint.go @@ -0,0 +1,61 @@ +package listing + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "strconv" + "fmt" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +type Handler func(http.ResponseWriter, *http.Request, httprouter.Params) + +// MakeAddBeerEndpoint creates a handler for GET /beers requests +func MakeGetBeersEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + list := s.GetBeers() + json.NewEncoder(w).Encode(list) + } +} + +// MakeAddBeeEndpoint creates a handler for GET /beers/:id requests +func MakeGetBeerEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + beer, err := s.GetBeer(ID) + if err == beers.ErrUnknown { + http.Error(w, "The beer you requested does not exist.", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(beer) + } +} + +// MakeGetBeerReviewsEndpoint creates a handler for GET /beers/:id/reviews requests +func MakeGetBeerReviewsEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + reviews, err := s.GetBeerReviews(ID) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid beer ID.", p.ByName("id")), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(reviews) + } +} diff --git a/domain-driven/listing/service.go b/domain-driven/listing/service.go new file mode 100644 index 0000000..57a9915 --- /dev/null +++ b/domain-driven/listing/service.go @@ -0,0 +1,43 @@ +package listing + +import ( + "github.com/katzien/structure-examples/domain-driven/reviews" + "github.com/katzien/structure-examples/domain-driven/beers" +) + +// Service provides beer or review adding operations +type Service interface { + GetBeers() []beers.Beer + GetBeer(int) (beers.Beer, error) + GetBeerReviews(int) ([]reviews.Review, error) +} + +type service struct { + bR beers.Repository + rR reviews.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(bR beers.Repository, rR reviews.Repository) Service { + return &service{bR, rR} +} + +// GetBeers returns all beers +func (s *service) GetBeers() []beers.Beer { + return s.bR.GetAll() +} + +// GetBeer returns a beer +func (s *service) GetBeer(id int) (beers.Beer, error) { + return s.bR.Get(id) +} + +// GetBeerReviews returns all requests for a beer +func (s *service) GetBeerReviews(beerID int) ([]reviews.Review, error) { + var list []reviews.Review + if _, err := s.bR.Get(beerID); err == beers.ErrUnknown { + return list, reviews.ErrNotFound + } + + return s.rR.GetAll(beerID), nil +} \ No newline at end of file diff --git a/domain-driven/main.go b/domain-driven/main.go new file mode 100644 index 0000000..9ac1e7a --- /dev/null +++ b/domain-driven/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "github.com/katzien/structure-examples/domain-driven/storage" + "github.com/katzien/structure-examples/domain-driven/adding" + "github.com/katzien/structure-examples/domain-driven/reviewing" + "github.com/katzien/structure-examples/domain-driven/listing" +) + +func main() { + + // set up storage + storageType := storage.InMemory // this could be a flag; hardcoded here for simplicity + + var beersStorage beers.Repository + var reviewsStorage reviews.Repository + + switch storageType { + case storage.InMemory: + beersStorage = new(storage.MemoryBeerStorage) + reviewsStorage = new(storage.MemoryReviewStorage) + case storage.JSONFiles: + // error handling omitted for simplicity + beersStorage, _ = storage.NewJSONBeerStorage(); + reviewsStorage, _ = storage.NewJSONReviewStorage(); + } + + // create the available services + adder := adding.NewService(beersStorage) + reviewer := reviewing.NewService(reviewsStorage) + lister := listing.NewService(beersStorage, reviewsStorage) + + // add some sample data + adder.AddSampleBeers() + reviewer.AddSampleReviews() + + // set up the HTTP server + router := httprouter.New() + + router.GET("/beers", listing.MakeGetBeersEndpoint(lister)) + router.GET("/beers/:id", listing.MakeGetBeerEndpoint(lister)) + router.GET("/beers/:id/reviews", listing.MakeGetBeerReviewsEndpoint(lister)) + + router.POST("/beers", adding.MakeAddBeerEndpoint(adder)) + router.POST("/beers/:id/reviews", reviewing.MakeAddBeerReviewEndpoint(reviewer)) + + fmt.Println("The beer server is on tap now: http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/domain-driven/reviewing/endpoint.go b/domain-driven/reviewing/endpoint.go new file mode 100644 index 0000000..49795ca --- /dev/null +++ b/domain-driven/reviewing/endpoint.go @@ -0,0 +1,37 @@ +package reviewing + +import ( + "github.com/julienschmidt/httprouter" + "net/http" + "encoding/json" + "strconv" + "fmt" + "github.com/katzien/structure-examples/domain-driven/reviews" +) + +type Handler func(http.ResponseWriter, *http.Request, httprouter.Params) + +// MakeAddBeerEndpoint creates a handler for POST /beers/:id/reviews requests +func MakeAddBeerReviewEndpoint(s Service) func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ID, err := strconv.Atoi(p.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", p.ByName("id")), http.StatusBadRequest) + return + } + + var newReview reviews.Review + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&newReview); err != nil { + http.Error(w, "Failed to parse review", http.StatusBadRequest) + } + + newReview.BeerID = ID + + s.AddBeerReview(newReview) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer review added.") + } +} diff --git a/domain-driven/reviewing/service.go b/domain-driven/reviewing/service.go new file mode 100644 index 0000000..9e2e5a2 --- /dev/null +++ b/domain-driven/reviewing/service.go @@ -0,0 +1,32 @@ +package reviewing + +import ( + "github.com/katzien/structure-examples/domain-driven/reviews" +) + +// Service provides beer or review adding operations +type Service interface { + AddBeerReview(r reviews.Review) + AddSampleReviews() +} + +type service struct { + rR reviews.Repository +} + +// NewService creates an adding service with the necessary dependencies +func NewService(rR reviews.Repository) Service { + return &service{rR} +} + +// AddBeerReview saves a new beer review in the database +func (s *service) AddBeerReview(r reviews.Review) { + _ = s.rR.Add(r) // error handling omitted for simplicity +} + +// AddSampleReviews adds some sample reviews to the database +func (s *service) AddSampleReviews() { + for _, b := range reviews.DefaultReviews { + _ = s.rR.Add(b) // error handling omitted for simplicity + } +} \ No newline at end of file diff --git a/domain-driven/reviews/review.go b/domain-driven/reviews/review.go new file mode 100644 index 0000000..558c00f --- /dev/null +++ b/domain-driven/reviews/review.go @@ -0,0 +1,26 @@ +package reviews + +import ( + "time" + "errors" +) + +// Review defines a beer review +type Review struct { + ID string `json:"id"` + BeerID int `json:"beer_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Score int `json:"score"` + Text string `json:"text"` + Created time.Time `json:"created"` +} + +// ErrNotFound is used when a beer could not be found. +var ErrNotFound = errors.New("beer not found") + +// Repository provides access to the reviews. +type Repository interface { + GetAll(beerID int) []Review + Add(review Review) error +} diff --git a/domain-driven/reviews/sample_reviews.go b/domain-driven/reviews/sample_reviews.go new file mode 100644 index 0000000..8556a71 --- /dev/null +++ b/domain-driven/reviews/sample_reviews.go @@ -0,0 +1,12 @@ +package reviews + +import "time" + +var DefaultReviews = []Review{ + {ID: "S_1", BeerID: 1, FirstName: "Joe", LastName: "Tribiani", Score: 5, Text: "This is good but this is not pizza!", Created: time.Date(2017, time.November, 10, 12, 36, 0, 0, time.UTC)}, + {ID: "S_2", BeerID: 2, FirstName: "Chandler", LastName: "Bing", Score: 1, Text: "I would SO NOT drink this ever again.", Created: time.Date(2017, time.October, 25, 5, 55, 0, 0, time.UTC)}, + {ID: "S_3", BeerID: 1, FirstName: "Ross", LastName: "Geller", Score: 4, Text: "Drank while on a break, was pretty good!", Created: time.Date(2017, time.October, 25, 12, 3, 0, 0, time.UTC)}, + {ID: "S_4", BeerID: 2, FirstName: "Phoebe", LastName: "Buffay", Score: 2, Text: "Wasn't that great, so I gave it to my smelly cat.", Created: time.Date(2017, time.October, 21, 16, 45, 0, 0, time.UTC)}, + {ID: "S_5", BeerID: 1, FirstName: "Monica", LastName: "Geller", Score: 5, Text: "AMAZING! Like Chandler's jokes!", Created: time.Date(2017, time.October, 22, 13, 41, 0, 0, time.UTC)}, + {ID: "S_6", BeerID: 2, FirstName: "Rachel", LastName: "Green", Score: 5, Text: "So yummy, just like my beef and custard trifle.", Created: time.Date(2017, time.October, 17, 9, 12, 0, 0, time.UTC)}, +} diff --git a/domain-driven/storage/json.go b/domain-driven/storage/json.go new file mode 100644 index 0000000..714617e --- /dev/null +++ b/domain-driven/storage/json.go @@ -0,0 +1,156 @@ +package storage + +import ( + "encoding/json" + "fmt" + + "github.com/nanobox-io/golang-scribble" + "strconv" + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "time" +) + +const ( + // location defines where the files are stored + location = "../../storage/json/" + + // CollectionBeer identifier for the JSON collection of beers + CollectionBeer = "beers" + // CollectionReview identifier for the JSON collection of reviews + CollectionReview = "reviews" +) + +// JSONBeerStorage stores beer data in JSON files +type JSONBeerStorage struct { + db *scribble.Driver +} + +// NewJSONBeerStorage returns a new JSON beer storage +func NewJSONBeerStorage() (*JSONBeerStorage, error) { + var err error + + s := new(JSONBeerStorage) + + s.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return s, nil +} + +// Add saves the given beer to the repository +func (s *JSONBeerStorage) Add(b beers.Beer) error { + var resource = strconv.Itoa(b.ID) + + existingBeers := s.GetAll() + for _, e := range existingBeers { + if b.Abv == e.Abv && + b.Brewery == e.Brewery && + b.Name == e.Name { + return beers.ErrDuplicate + } + } + + b.ID = len(existingBeers) + 1 + b.Created = time.Now() + + if err := s.db.Write(CollectionBeer, resource, b); err != nil { + return err + } + return nil +} + +// Get returns a beer with the specified ID +func (s *JSONBeerStorage) Get(id int) (beers.Beer, error) { + var beer beers.Beer + var resource = strconv.Itoa(id) + + if err := s.db.Read(CollectionBeer, resource, &beer); err != nil { + return beer, beers.ErrUnknown + } + + return beer, nil +} + +// GetAll returns all beers +func (s *JSONBeerStorage) GetAll() []beers.Beer { + var list []beers.Beer + + records, err := s.db.ReadAll(CollectionBeer) + if err != nil { + panic("error while fetching beers from the JSON file storage: %v" + err.Error()) + } + + for _, b := range records { + var beer beers.Beer + + if err := json.Unmarshal([]byte(b), &beer); err != nil { + return list + } + + list = append(list, beer) + } + + return list +} + +// JSONReviewStorage stores review data in JSON files +type JSONReviewStorage struct { + db *scribble.Driver +} + +// NewJSONReviewStorage returns a new JSON reviews storage +func NewJSONReviewStorage() (*JSONReviewStorage, error) { + var err error + + s := new(JSONReviewStorage) + + s.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return s, nil +} + +// Add saves the given review in the repository +func (s *JSONReviewStorage) Add(r reviews.Review) error { + + var beer beers.Beer + if err := s.db.Read(CollectionBeer, strconv.Itoa(r.BeerID), &beer); err != nil { + return reviews.ErrNotFound + } + + r.ID = fmt.Sprintf("%s_%s_%s_%s", r.BeerID, r.FirstName, r.LastName, r.Created.Unix()) + r.Created = time.Now() + + if err := s.db.Write(CollectionReview, r.ID, r); err != nil { + return err + } + + return nil +} + +// GetAll returns all reviews for a given beer +func (s *JSONReviewStorage) GetAll(beerID int) []reviews.Review { + var list []reviews.Review + + records, err := s.db.ReadAll(CollectionReview) + if err != nil { + panic("error while fetching reviews from the JSON file storage: " + err.Error()) + } + + for _, r := range records { + var review reviews.Review + + if err := json.Unmarshal([]byte(r), &review); err != nil { + return list + } + + list = append(list, review) + } + + return list +} diff --git a/domain-driven/storage/memory.go b/domain-driven/storage/memory.go new file mode 100644 index 0000000..a6d5615 --- /dev/null +++ b/domain-driven/storage/memory.go @@ -0,0 +1,89 @@ +package storage + +import ( + "github.com/katzien/structure-examples/domain-driven/beers" + "github.com/katzien/structure-examples/domain-driven/reviews" + "time" + "fmt" +) + +// Memory storage keeps beer data in memory +type MemoryBeerStorage struct { + beers []beers.Beer + reviews []reviews.Review +} + +// Add saves the given beer to the repository +func (m *MemoryBeerStorage) Add(b beers.Beer) error { + for _, e := range m.beers { + if b.Abv == e.Abv && + b.Brewery == e.Brewery && + b.Name == e.Name { + return beers.ErrDuplicate + } + } + + b.ID = len(m.beers) + 1 + b.Created = time.Now() + m.beers = append(m.beers, b) + + return nil +} + +// Get returns a beer with the specified ID +func (m *MemoryBeerStorage) Get(id int) (beers.Beer, error) { + var beer beers.Beer + + for i := range m.beers { + + if m.beers[i].ID == id { + return m.beers[i], nil + } + } + + return beer, beers.ErrUnknown +} + +// GetAll return all beers +func (m *MemoryBeerStorage) GetAll() []beers.Beer { + return m.beers +} + +// Memory storage keeps review data in memory +type MemoryReviewStorage struct { + beers []beers.Beer + reviews []reviews.Review +} + +// Add saves the given review in the repository +func (m *MemoryReviewStorage) Add(r reviews.Review) error { + found := false + for b := range m.beers { + if m.beers[b].ID == r.BeerID { + found = true + } + } + + if found { + r.ID = fmt.Sprintf("%s_%s_%s_%s", r.BeerID, r.FirstName, r.LastName, r.Created.Unix()) + r.Created = time.Now() + m.reviews = append(m.reviews, r) + } else { + return reviews.ErrNotFound + } + + return nil +} + +// GetAll returns all reviews for a given beer +func (m *MemoryReviewStorage) GetAll(beerID int) []reviews.Review { + var list []reviews.Review + + for i := range m.reviews { + if m.reviews[i].BeerID == beerID { + list = append(list, m.reviews[i]) + } + } + + return list +} diff --git a/domain-driven/storage/type.go b/domain-driven/storage/type.go new file mode 100644 index 0000000..d1f0597 --- /dev/null +++ b/domain-driven/storage/type.go @@ -0,0 +1,11 @@ +package storage + +// StorageType defines available storage types +type Type int + +const ( + // JSON will store data in JSON files saved on disk + JSONFiles Type = iota + // Memory will store data in memory + InMemory +) \ No newline at end of file diff --git a/flat/Gopkg.toml b/flat/Gopkg.toml new file mode 100644 index 0000000..cec6105 --- /dev/null +++ b/flat/Gopkg.toml @@ -0,0 +1,24 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +[[constraint]] + name = "github.com/julienschmidt/httprouter" + version = "1.1.0" diff --git a/flat/data.go b/flat/data.go new file mode 100644 index 0000000..b158c66 --- /dev/null +++ b/flat/data.go @@ -0,0 +1,132 @@ +package main + +import "time" + +// PopulateBeers populates the Cellar variable with Beers +func PopulateBeers() { + defaultBeers := []Beer{ + Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + }, + Beer{ + ID: 2, + Name: "Oatmeal Stout", + Brewery: "Samuel Smith", + Abv: 5, + ShortDesc: "Brewed with well water (the original well at the " + + "Old Brewery, sunk in 1758, is still in use, with the hard well " + + "water being drawn from 85 feet underground); fermented in " + + "‘stone Yorkshire squares’ to create an almost opaque, " + + "wonderfully silky and smooth textured ale with a complex " + + "medium dry palate and bittersweet finish.", + Created: time.Date(2017, time.October, 24, 22, 12, 0, 0, time.UTC), + }, + Beer{ + ID: 3, + Name: "Märzen", + Brewery: "Schlenkerla", + Abv: 5, + ShortDesc: "Bamberg's speciality, a dark, bottom fermented " + + "smokebeer, brewed with Original Schlenkerla Smokemalt from " + + "the Schlenkerla maltings and tapped according to old tradition " + + "directly from the gravity-fed oakwood cask in the historical " + + "brewery tavern.", + Created: time.Date(2017, time.October, 24, 22, 17, 0, 0, time.UTC), + }, + Beer{ + ID: 4, + Name: "Duvel", + Brewery: "Duvel Moortgat", + Abv: 9, + ShortDesc: "A Duvel is still seen as the reference among strong " + + "golden ales. Its bouquet is lively and tickles the nose with an " + + "element of citrus which even tends towards grapefruit thanks to " + + "the use of only the highest-quality hop varieties.", + Created: time.Date(2017, time.October, 24, 22, 24, 0, 0, time.UTC), + }, + Beer{ + ID: 5, + Name: "Negra", + Brewery: "Modelo", + Abv: 5, + ShortDesc: "Brewed longer to enhance the flavors, this Munich " + + "Dunkel-style Lager gives way to a rich flavor and remarkably " + + "smooth taste.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + Beer{ + ID: 6, + Name: "Guinness Draught", + Brewery: "Guinness Ltd.", + Abv: 4, + ShortDesc: "Pours dark brown, almost black with solid lasting light brown head. " + + "Aroma of bitter cocoa, light coffee and roasted malt. " + + "Body is light sweet, medium bitter. " + + "Body is light to medium, texture almost thin and carbonation average. " + + "Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + Beer{ + ID: 7, + Name: "XX Lager", + Brewery: "Cuahutemoc Moctezuma", + Abv: 4.2, + ShortDesc: "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. " + + "A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. " + + "A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + Created: time.Date(2017, time.October, 28, 15, 02, 0, 0, time.UTC), + }, + Beer{ + ID: 8, + Name: "Tecate", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. " + + "Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. " + + "Finishes a bit dry with husk tannins and a pasty mouthfeel.", + Created: time.Date(2017, time.October, 28, 15, 07, 0, 0, time.UTC), + }, + Beer{ + ID: 9, + Name: "Sol", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. " + + "You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, " + + "but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + Created: time.Date(2017, time.October, 28, 15, 12, 0, 0, time.UTC), + }, + Beer{ + ID: 10, + Name: "Corona", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "One of the five best-selling beers in the world, but it usually tastes better in Mexico, " + + "where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) " + + "This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + Created: time.Date(2017, time.October, 28, 15, 14, 0, 0, time.UTC), + }, + } + db.SaveBeer(defaultBeers...) +} + +// PopulateReviews populates the Reviews variable with Reviews +func PopulateReviews() { + defaultReviews := []Review{ + Review{ID: 1, BeerID: 1, FirstName: "Joe", LastName: "Tribiani", Score: 5, Text: "This is good but this is not pizza!", Created: time.Date(2017, time.November, 10, 12, 36, 0, 0, time.UTC)}, + Review{ID: 2, BeerID: 2, FirstName: "Chandler", LastName: "Bing", Score: 1, Text: "I would SO NOT drink this ever again.", Created: time.Date(2017, time.October, 25, 5, 55, 0, 0, time.UTC)}, + Review{ID: 3, BeerID: 1, FirstName: "Ross", LastName: "Geller", Score: 4, Text: "Drank while on a break, was pretty good!", Created: time.Date(2017, time.October, 25, 12, 3, 0, 0, time.UTC)}, + Review{ID: 4, BeerID: 2, FirstName: "Phoebe", LastName: "Buffay", Score: 2, Text: "Wasn't that great, so I gave it to my smelly cat.", Created: time.Date(2017, time.October, 21, 16, 45, 0, 0, time.UTC)}, + Review{ID: 5, BeerID: 1, FirstName: "Monica", LastName: "Geller", Score: 5, Text: "AMAZING! Like Chandler's jokes!", Created: time.Date(2017, time.October, 22, 13, 41, 0, 0, time.UTC)}, + Review{ID: 6, BeerID: 2, FirstName: "Rachel", LastName: "Green", Score: 5, Text: "So yummy, just like my beef and custard trifle.", Created: time.Date(2017, time.October, 17, 9, 12, 0, 0, time.UTC)}, + } + db.SaveReview(defaultReviews...) +} diff --git a/flat/handlers.go b/flat/handlers.go new file mode 100644 index 0000000..aaffbee --- /dev/null +++ b/flat/handlers.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" +) + +// GetBeers returns the cellar +func GetBeers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + cellar := db.FindBeers() + json.NewEncoder(w).Encode(cellar) +} + +// GetBeer returns a beer from the cellar +func GetBeer(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + cellar, _ := db.FindBeer(Beer{ID: ID}) + if len(cellar) == 1 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cellar[0]) + return + } + + http.Error(w, "The beer you requested does not exist.", http.StatusNotFound) +} + +// GetBeerReviews returns all reviews for a beer +func GetBeerReviews(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + // TODO: Consider checking if a beer matching the ID actually exists, and + // 404 if that is not the case. + + results, _ := db.FindReview(Review{BeerID: ID}) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +// AddBeer adds a new beer to the cellar +func AddBeer(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + decoder := json.NewDecoder(r.Body) + + var newBeer Beer + err := decoder.Decode(&newBeer) + if err != nil { + http.Error(w, "Bad beer - this will be a HTTP status code soon!", http.StatusBadRequest) + return + } + + db.SaveBeer(newBeer) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer added.") +} + +// AddBeerReview adds a new review for a beer +func AddBeerReview(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + var newReview Review + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&newReview); err != nil { + http.Error(w, "Failed to parse review", http.StatusBadRequest) + } + + newReview.BeerID = ID + if err := db.SaveReview(newReview); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer review added.") + +} diff --git a/flat/handlers_test.go b/flat/handlers_test.go new file mode 100644 index 0000000..84ba6a7 --- /dev/null +++ b/flat/handlers_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetBeers(t *testing.T) { + var cellarFromRequest []Beer + var cellarFromStorage []Beer + + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "/beers", nil) + + router.ServeHTTP(w, r) + + cellarFromStorage = db.FindBeers() + json.Unmarshal(w.Body.Bytes(), &cellarFromRequest) + + if w.Code != http.StatusOK { + t.Errorf("Expected route GET /beers to be valid.") + t.FailNow() + } + + if len(cellarFromRequest) != len(cellarFromStorage) { + t.Error("Expected number of beers from request to be the same as beers in the storage") + t.FailNow() + } + + var mapCellar = make(map[Beer]int, len(cellarFromStorage)) + for _, beer := range cellarFromStorage { + mapCellar[beer] = 1 + } + + for _, beerResp := range cellarFromRequest { + if _, ok := mapCellar[beerResp]; !ok { + t.Errorf("Expected all results to match existing records") + t.FailNow() + break + } + } +} + +func TestAddBeer(t *testing.T) { + newBeer := Beer{ + Name: "Testing beer", + Abv: 333, + Brewery: "Testing Beer Inc", + } + + newBeerJSON, err := json.Marshal(newBeer) + if err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest("POST", "/beers", bytes.NewBuffer(newBeerJSON)) + + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Expected route POST /beers to be valid.") + t.FailNow() + } + + newBeerMissing := true + for _, b := range db.FindBeers() { + if b.Name == newBeer.Name && + b.Abv == newBeer.Abv && + b.Brewery == newBeer.Brewery { + newBeerMissing = false + } + } + + if newBeerMissing { + t.Errorf("Expected to find new entry in storage`") + t.FailNow() + } + +} + +func TestGetBeer(t *testing.T) { + cellar := db.FindBeers() + choice := rand.Intn(len(cellar) - 1) + + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", fmt.Sprintf("/beers/%d", cellar[choice].ID), nil) + + router.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Expected route GET /beers/%d to be valid.", cellar[choice].ID) + t.FailNow() + } + + var selectedBeer Beer + json.Unmarshal(w.Body.Bytes(), &selectedBeer) + + if cellar[choice] != selectedBeer { + t.Errorf("Expected to match results with selected beer") + t.FailNow() + } + +} diff --git a/flat/main.go b/flat/main.go new file mode 100644 index 0000000..732d79b --- /dev/null +++ b/flat/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +// db is an interface to interact with data on multiple layered of data storage +var db Storage +var router *httprouter.Router + +func init() { + var err error + + db, err = NewStorage(Memory) + if err != nil { + log.Fatal(err) + } + + PopulateBeers() + PopulateReviews() + + router = httprouter.New() + + router.GET("/beers", GetBeers) + router.GET("/beers/:id", GetBeer) + router.GET("/beers/:id/reviews", GetBeerReviews) + + router.POST("/beers", AddBeer) + router.POST("/beers/:id/reviews", AddBeerReview) +} + +func main() { + fmt.Println("The beer server is on tap now.") + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/flat/model.go b/flat/model.go new file mode 100644 index 0000000..6e5c396 --- /dev/null +++ b/flat/model.go @@ -0,0 +1,24 @@ +package main + +import "time" + +// Review defines the properties of a beer review +type Review struct { + ID int `json:"id"` + BeerID int `json:"beer_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Score int `json:"score"` + Text string `json:"text"` + Created time.Time `json:"created"` +} + +// Beer defines the properties of a beer +type Beer struct { + ID int `json:"id"` + Name string `json:"name"` + Brewery string `json:"brewery"` + Abv float32 `json:"abv"` + ShortDesc string `json:"short_description"` + Created time.Time `json:"created"` +} diff --git a/flat/storage.go b/flat/storage.go new file mode 100644 index 0000000..bff9333 --- /dev/null +++ b/flat/storage.go @@ -0,0 +1,37 @@ +package main + +// StorageType defines available storage types +type StorageType int + +const ( + // JSON will store data in JSON files saved on disk + JSON StorageType = iota + // Memory will store data in memory + Memory +) + +// Storage represents all possible actions available to deal with data +type Storage interface { + SaveBeer(...Beer) error + SaveReview(...Review) error + FindBeer(Beer) ([]*Beer, error) + FindReview(Review) ([]*Review, error) + FindBeers() []Beer + FindReviews() []Review +} + +func NewStorage(storageType StorageType) (Storage, error) { + var stg Storage + var err error + + switch storageType { + case Memory: + stg = new(StorageMemory) + + case JSON: + // for the moment storage location for JSON files is the current working directory + stg, err = newStorageJSON("./data/") + } + + return stg, err +} diff --git a/flat/storage_json.go b/flat/storage_json.go new file mode 100644 index 0000000..74a08c5 --- /dev/null +++ b/flat/storage_json.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/nanobox-io/golang-scribble" +) + +const ( + // CollectionBeer identifier for JSON collection about beers + CollectionBeer int = iota + // CollectionReview identifier for JSON collection about reviews + CollectionReview +) + +// StorageJSON is the data storage layered using JSON file +type StorageJSON struct { + db *scribble.Driver +} + +func newStorageJSON(location string) (*StorageJSON, error) { + var err error + + stg := new(StorageJSON) + + stg.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return stg, nil +} + +// SaveBeer insert new beers +func (s *StorageJSON) SaveBeer(beers ...Beer) error { + for _, beer := range beers { + var resource = strconv.Itoa(beer.ID) + var collection = strconv.Itoa(CollectionBeer) + + allBeers := s.FindBeers() + for _, b := range allBeers { + if beer.Abv == b.Abv && + beer.Brewery == b.Brewery && + beer.Name == b.Name { + return fmt.Errorf("Beer already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of beers should always increase. + beer.ID = len(allBeers) + 1 + + if err := s.db.Write(collection, resource, beer); err != nil { + return err + } + } + return nil +} + +// SaveReview insert reviews +func (s *StorageJSON) SaveReview(reviews ...Review) error { + for _, review := range reviews { + var resource = strconv.Itoa(review.ID) + var collection = strconv.Itoa(CollectionReview) + + beerFound, err := s.FindBeer(Beer{ID: review.BeerID}) + if err != nil { + return err + } + + if len(beerFound) == 0 { + return fmt.Errorf("The beer selected for the review does not exist") + } + + allReviews := s.FindReviews() + for _, r := range allReviews { + if review.BeerID == r.BeerID && + review.FirstName == r.FirstName && + review.LastName == r.LastName && + review.Text == r.Text { + return fmt.Errorf("Review already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of reviews should always increase. + review.ID = len(allReviews) + 1 + + if err = s.db.Write(collection, resource, review); err != nil { + return err + } + } + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *StorageJSON) FindBeer(criteria Beer) ([]*Beer, error) { + var beers []*Beer + var beer Beer + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionBeer) + + if err := s.db.Read(collection, resource, &beer); err != nil { + return beers, err + } + + beers = append(beers, &beer) + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *StorageJSON) FindReview(criteria Review) ([]*Review, error) { + var reviews []*Review + var review Review + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionReview) + + if err := s.db.Read(collection, resource, &review); err != nil { + return reviews, err + } + + reviews = append(reviews, &review) + + return reviews, nil +} + +func (s *StorageJSON) FindBeers() []Beer { + var beers []Beer + var collection = strconv.Itoa(CollectionBeer) + + records, err := s.db.ReadAll(collection) + if err != nil { + return beers + } + + for _, b := range records { + var beer Beer + + if err := json.Unmarshal([]byte(b), &beer); err != nil { + return beers + } + + beers = append(beers, beer) + } + + return beers +} + +func (s *StorageJSON) FindReviews() []Review { + var reviews []Review + var collection = strconv.Itoa(CollectionReview) + + records, err := s.db.ReadAll(collection) + if err != nil { + return reviews + } + + for _, r := range records { + var review Review + + if err := json.Unmarshal([]byte(r), &review); err != nil { + return reviews + } + + reviews = append(reviews, review) + } + + return reviews +} diff --git a/flat/storage_json_test.go b/flat/storage_json_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/flat/storage_json_test.go @@ -0,0 +1 @@ +package main diff --git a/flat/storage_mem.go b/flat/storage_mem.go new file mode 100644 index 0000000..8df26ae --- /dev/null +++ b/flat/storage_mem.go @@ -0,0 +1,88 @@ +package main + +// StorageMemory data storage layered save only in memory +type StorageMemory struct { + cellar []Beer + reviews []Review +} + +// SaveBeer insert or update beers +func (s *StorageMemory) SaveBeer(beers ...Beer) error { + for _, beer := range beers { + var err error + + beersFound, err := s.FindBeer(beer) + if err != nil { + return err + } + + if len(beersFound) == 1 { + *beersFound[0] = beer + return nil + } + + beer.ID = len(s.cellar) + 1 + s.cellar = append(s.cellar, beer) + } + + return nil +} + +// SaveReview insert or update reviews +func (s *StorageMemory) SaveReview(reviews ...Review) error { + for _, review := range reviews { + var err error + + reviewsFound, err := s.FindReview(review) + if err != nil { + return err + } + + if len(reviewsFound) == 1 { + *reviewsFound[0] = review + return nil + } + + review.ID = len(s.reviews) + 1 + s.reviews = append(s.reviews, review) + } + + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *StorageMemory) FindBeer(criteria Beer) ([]*Beer, error) { + var beers []*Beer + + for idx := range s.cellar { + + if s.cellar[idx].ID == criteria.ID { + beers = append(beers, &s.cellar[idx]) + } + } + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *StorageMemory) FindReview(criteria Review) ([]*Review, error) { + var reviews []*Review + + for idx := range s.reviews { + if s.reviews[idx].ID == criteria.ID || s.reviews[idx].BeerID == criteria.BeerID { + reviews = append(reviews, &s.reviews[idx]) + } + } + + return reviews, nil +} + +// FindBeers return all beers +func (s *StorageMemory) FindBeers() []Beer { + return s.cellar +} + +// FindReviews return all reviews +func (s *StorageMemory) FindReviews() []Review { + return s.reviews +} diff --git a/flat/storage_mem_test.go b/flat/storage_mem_test.go new file mode 100644 index 0000000..5e73301 --- /dev/null +++ b/flat/storage_mem_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + "time" +) + +func TestSaveBeer(t *testing.T) { + + storage := new(StorageMemory) + sampleBeer := Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + } + storage.SaveBeer(sampleBeer) + + if len(storage.cellar) == 0 { + t.Errorf("Expected sample beer to be added to storage list.") + t.FailNow() + } + + sampleBeerChange := sampleBeer + sampleBeerChange.Name = "Not a beer name" + storage.SaveBeer(sampleBeerChange) + + if len(storage.cellar) > 1 { + t.Errorf("Expected sample beer to be updated instead of creating another entry.") + t.FailNow() + } + + if storage.cellar[0].Name == sampleBeer.Name { + t.Errorf("Expected sample beer name to be updated with new name.") + t.FailNow() + } +} diff --git a/flat/storage_test.go b/flat/storage_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/flat/storage_test.go @@ -0,0 +1 @@ +package main diff --git a/layered/Gopkg.toml b/layered/Gopkg.toml new file mode 100644 index 0000000..cec6105 --- /dev/null +++ b/layered/Gopkg.toml @@ -0,0 +1,24 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +[[constraint]] + name = "github.com/julienschmidt/httprouter" + version = "1.1.0" diff --git a/layered/data.go b/layered/data.go new file mode 100644 index 0000000..a901a41 --- /dev/null +++ b/layered/data.go @@ -0,0 +1,135 @@ +package main + +import ( + "time" + "github.com/katzien/structure-examples/layered/models" +) + +// PopulateBeers populates the Cellar variable with Beers +func PopulateBeers() { + defaultBeers := []models.Beer{ + models.Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + }, + models.Beer{ + ID: 2, + Name: "Oatmeal Stout", + Brewery: "Samuel Smith", + Abv: 5, + ShortDesc: "Brewed with well water (the original well at the " + + "Old Brewery, sunk in 1758, is still in use, with the hard well " + + "water being drawn from 85 feet underground); fermented in " + + "‘stone Yorkshire squares’ to create an almost opaque, " + + "wonderfully silky and smooth textured ale with a complex " + + "medium dry palate and bittersweet finish.", + Created: time.Date(2017, time.October, 24, 22, 12, 0, 0, time.UTC), + }, + models.Beer{ + ID: 3, + Name: "Märzen", + Brewery: "Schlenkerla", + Abv: 5, + ShortDesc: "Bamberg's speciality, a dark, bottom fermented " + + "smokebeer, brewed with Original Schlenkerla Smokemalt from " + + "the Schlenkerla maltings and tapped according to old tradition " + + "directly from the gravity-fed oakwood cask in the historical " + + "brewery tavern.", + Created: time.Date(2017, time.October, 24, 22, 17, 0, 0, time.UTC), + }, + models.Beer{ + ID: 4, + Name: "Duvel", + Brewery: "Duvel Moortgat", + Abv: 9, + ShortDesc: "A Duvel is still seen as the reference among strong " + + "golden ales. Its bouquet is lively and tickles the nose with an " + + "element of citrus which even tends towards grapefruit thanks to " + + "the use of only the highest-quality hop varieties.", + Created: time.Date(2017, time.October, 24, 22, 24, 0, 0, time.UTC), + }, + models.Beer{ + ID: 5, + Name: "Negra", + Brewery: "Modelo", + Abv: 5, + ShortDesc: "Brewed longer to enhance the flavors, this Munich " + + "Dunkel-style Lager gives way to a rich flavor and remarkably " + + "smooth taste.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + models.Beer{ + ID: 6, + Name: "Guinness Draught", + Brewery: "Guinness Ltd.", + Abv: 4, + ShortDesc: "Pours dark brown, almost black with solid lasting light brown head. " + + "Aroma of bitter cocoa, light coffee and roasted malt. " + + "Body is light sweet, medium bitter. " + + "Body is light to medium, texture almost thin and carbonation average. " + + "Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + models.Beer{ + ID: 7, + Name: "XX Lager", + Brewery: "Cuahutemoc Moctezuma", + Abv: 4.2, + ShortDesc: "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. " + + "A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. " + + "A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + Created: time.Date(2017, time.October, 28, 15, 02, 0, 0, time.UTC), + }, + models.Beer{ + ID: 8, + Name: "Tecate", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. " + + "Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. " + + "Finishes a bit dry with husk tannins and a pasty mouthfeel.", + Created: time.Date(2017, time.October, 28, 15, 07, 0, 0, time.UTC), + }, + models.Beer{ + ID: 9, + Name: "Sol", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. " + + "You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, " + + "but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + Created: time.Date(2017, time.October, 28, 15, 12, 0, 0, time.UTC), + }, + models.Beer{ + ID: 10, + Name: "Corona", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "One of the five best-selling beers in the world, but it usually tastes better in Mexico, " + + "where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) " + + "This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + Created: time.Date(2017, time.October, 28, 15, 14, 0, 0, time.UTC), + }, + } + models.DB.SaveBeer(defaultBeers...) +} + +// PopulateReviews populates the Reviews variable with Reviews +func PopulateReviews() { + defaultReviews := []models.Review{ + models.Review{ID: 1, BeerID: 1, FirstName: "Joe", LastName: "Tribiani", Score: 5, Text: "This is good but this is not pizza!", Created: time.Date(2017, time.November, 10, 12, 36, 0, 0, time.UTC)}, + models.Review{ID: 2, BeerID: 2, FirstName: "Chandler", LastName: "Bing", Score: 1, Text: "I would SO NOT drink this ever again.", Created: time.Date(2017, time.October, 25, 5, 55, 0, 0, time.UTC)}, + models.Review{ID: 3, BeerID: 1, FirstName: "Ross", LastName: "Geller", Score: 4, Text: "Drank while on a break, was pretty good!", Created: time.Date(2017, time.October, 25, 12, 3, 0, 0, time.UTC)}, + models.Review{ID: 4, BeerID: 2, FirstName: "Phoebe", LastName: "Buffay", Score: 2, Text: "Wasn't that great, so I gave it to my smelly cat.", Created: time.Date(2017, time.October, 21, 16, 45, 0, 0, time.UTC)}, + models.Review{ID: 5, BeerID: 1, FirstName: "Monica", LastName: "Geller", Score: 5, Text: "AMAZING! Like Chandler's jokes!", Created: time.Date(2017, time.October, 22, 13, 41, 0, 0, time.UTC)}, + models.Review{ID: 6, BeerID: 2, FirstName: "Rachel", LastName: "Green", Score: 5, Text: "So yummy, just like my beef and custard trifle.", Created: time.Date(2017, time.October, 17, 9, 12, 0, 0, time.UTC)}, + } + models.DB.SaveReview(defaultReviews...) +} diff --git a/layered/handlers/beers.go b/layered/handlers/beers.go new file mode 100644 index 0000000..44faa2e --- /dev/null +++ b/layered/handlers/beers.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/layered/models" +) + +// GetBeers returns the cellar +func GetBeers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + cellar := models.DB.FindBeers() + json.NewEncoder(w).Encode(cellar) +} + +// GetBeer returns a beer from the cellar +func GetBeer(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + cellar, _ := models.DB.FindBeer(models.Beer{ID: ID}) + if len(cellar) == 1 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cellar[0]) + return + } + + http.Error(w, "The beer you requested does not exist.", http.StatusNotFound) +} + +// AddBeer adds a new beer to the cellar +func AddBeer(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + decoder := json.NewDecoder(r.Body) + + var newBeer models.Beer + err := decoder.Decode(&newBeer) + if err != nil { + http.Error(w, "Bad beer - this will be a HTTP status code soon!", http.StatusBadRequest) + return + } + + models.DB.SaveBeer(newBeer) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer added.") +} diff --git a/layered/handlers/beers_test.go b/layered/handlers/beers_test.go new file mode 100644 index 0000000..2c27fa4 --- /dev/null +++ b/layered/handlers/beers_test.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "net/http/httptest" + "github.com/vulcand/vulcand/router" + "bytes" + "fmt" + "testing" + "github.com/katzien/structure-examples/layered/models" + "encoding/json" + "math/rand" + "net/http" +) + +//func TestGetBeers(t *testing.T) { +// var cellarFromRequest []models.Beer +// var cellarFromStorage []models.Beer +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("GET", "/beers", nil) +// +// router.ServeHTTP(w, r) +// +// cellarFromStorage = models.DB.FindBeers() +// json.Unmarshal(w.Body.Bytes(), &cellarFromRequest) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route GET /beers to be valid.") +// t.FailNow() +// } +// +// if len(cellarFromRequest) != len(cellarFromStorage) { +// t.Error("Expected number of beers from request to be the same as beers in the storage") +// t.FailNow() +// } +// +// var mapCellar = make(map[models.Beer]int, len(cellarFromStorage)) +// for _, beer := range cellarFromStorage { +// mapCellar[beer] = 1 +// } +// +// for _, beerResp := range cellarFromRequest { +// if _, ok := mapCellar[beerResp]; !ok { +// t.Errorf("Expected all results to match existing records") +// t.FailNow() +// break +// } +// } +//} +// +//func TestAddBeer(t *testing.T) { +// newBeer := models.Beer{ +// Name: "Testing beer", +// Abv: 333, +// Brewery: "Testing Beer Inc", +// } +// +// newBeerJSON, err := json.Marshal(newBeer) +// if err != nil { +// t.Fatal(err) +// } +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("POST", "/beers", bytes.NewBuffer(newBeerJSON)) +// +// router.ServeHTTP(w, r) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route POST /beers to be valid.") +// t.FailNow() +// } +// +// newBeerMissing := true +// for _, b := range models.DB.FindBeers() { +// if b.Name == newBeer.Name && +// b.Abv == newBeer.Abv && +// b.Brewery == newBeer.Brewery { +// newBeerMissing = false +// } +// } +// +// if newBeerMissing { +// t.Errorf("Expected to find new entry in storage`") +// t.FailNow() +// } +//} +// +//func TestGetBeer(t *testing.T) { +// cellar := models.DB.FindBeers() +// choice := rand.Intn(len(cellar) - 1) +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("GET", fmt.Sprintf("/beers/%d", cellar[choice].ID), nil) +// +// router.ServeHTTP(w, r) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route GET /beers/%d to be valid.", cellar[choice].ID) +// t.FailNow() +// } +// +// var selectedBeer models.Beer +// json.Unmarshal(w.Body.Bytes(), &selectedBeer) +// +// if cellar[choice] != selectedBeer { +// t.Errorf("Expected to match results with selected beer") +// t.FailNow() +// } +//} diff --git a/layered/handlers/reviews.go b/layered/handlers/reviews.go new file mode 100644 index 0000000..ce60f3d --- /dev/null +++ b/layered/handlers/reviews.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/layered/models" +) + +// GetBeerReviews returns all reviews for a beer +func GetBeerReviews(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + // TODO: Consider checking if a beer matching the ID actually exists, and + // 404 if that is not the case. + + results, _ := models.DB.FindReview(models.Review{BeerID: ID}) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +// AddBeerReview adds a new review for a beer +func AddBeerReview(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + var newReview models.Review + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&newReview); err != nil { + http.Error(w, "Failed to parse review", http.StatusBadRequest) + } + + newReview.BeerID = ID + if err := models.DB.SaveReview(newReview); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer review added.") + +} diff --git a/layered/handlers/reviews_test.go b/layered/handlers/reviews_test.go new file mode 100644 index 0000000..5ac8282 --- /dev/null +++ b/layered/handlers/reviews_test.go @@ -0,0 +1 @@ +package handlers diff --git a/layered/main.go b/layered/main.go new file mode 100644 index 0000000..e096638 --- /dev/null +++ b/layered/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/layered/models" + "github.com/katzien/structure-examples/layered/handlers" +) + +var router *httprouter.Router + +func init() { + var err error + + err = models.NewStorage(models.Memory) + if err != nil { + log.Fatal(err) + } + + PopulateBeers() + PopulateReviews() + + router = httprouter.New() + + router.GET("/beers", handlers.GetBeers) + router.GET("/beers/:id", handlers.GetBeer) + router.GET("/beers/:id/reviews", handlers.GetBeerReviews) + + router.POST("/beers", handlers.AddBeer) + router.POST("/beers/:id/reviews", handlers.AddBeerReview) +} + +func main() { + fmt.Println("The beer server is on tap now.") + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/layered/models/beer.go b/layered/models/beer.go new file mode 100644 index 0000000..69b00a8 --- /dev/null +++ b/layered/models/beer.go @@ -0,0 +1,13 @@ +package models + +import "time" + +// Beer defines the properties of a beer +type Beer struct { + ID int `json:"id"` + Name string `json:"name"` + Brewery string `json:"brewery"` + Abv float32 `json:"abv"` + ShortDesc string `json:"short_description"` + Created time.Time `json:"created"` +} \ No newline at end of file diff --git a/layered/models/review.go b/layered/models/review.go new file mode 100644 index 0000000..71f4619 --- /dev/null +++ b/layered/models/review.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// Review defines the properties of a beer review +type Review struct { + ID int `json:"id"` + BeerID int `json:"beer_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Score int `json:"score"` + Text string `json:"text"` + Created time.Time `json:"created"` +} \ No newline at end of file diff --git a/layered/models/storage.go b/layered/models/storage.go new file mode 100644 index 0000000..1581cc8 --- /dev/null +++ b/layered/models/storage.go @@ -0,0 +1,44 @@ +package models + +import "github.com/katzien/structure-examples/layered/storage" + +// StorageType defines available storage types +type StorageType int + +const ( + // JSON will store data in JSON files saved on disk + JSON StorageType = iota + // Memory will store data in memory + Memory +) + +// DB is an interface to interact with data on multiple layered of data storage +var DB Storage + +// Storage represents all possible actions available to deal with data +type Storage interface { + SaveBeer(...Beer) error + SaveReview(...Review) error + FindBeer(Beer) ([]*Beer, error) + FindReview(Review) ([]*Review, error) + FindBeers() []Beer + FindReviews() []Review +} + +func NewStorage(storageType StorageType) error { + var err error + + switch storageType { + case Memory: + DB = new(storage.Memory) + + case JSON: + // for the moment storage location for JSON files is the current working directory + DB, err = storage.NewJSON("./data/") + if err != nil { + return err + } + } + + return nil +} diff --git a/layered/models/storage_test.go b/layered/models/storage_test.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/layered/models/storage_test.go @@ -0,0 +1 @@ +package models diff --git a/layered/storage/json.go b/layered/storage/json.go new file mode 100644 index 0000000..6c81e0b --- /dev/null +++ b/layered/storage/json.go @@ -0,0 +1,173 @@ +package storage + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/nanobox-io/golang-scribble" + "github.com/katzien/structure-examples/layered/models" +) + +const ( + // CollectionBeer identifier for JSON collection about beers + CollectionBeer int = iota + // CollectionReview identifier for JSON collection about reviews + CollectionReview +) + +// JSON is the data storage layered using JSON file +type JSON struct { + db *scribble.Driver +} + +func NewJSON(location string) (*JSON, error) { + var err error + + stg := new(JSON) + + stg.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return stg, nil +} + +// SaveBeer insert new beers +func (s *JSON) SaveBeer(beers ...models.Beer) error { + for _, beer := range beers { + var resource = strconv.Itoa(beer.ID) + var collection = strconv.Itoa(CollectionBeer) + + allBeers := s.FindBeers() + for _, b := range allBeers { + if beer.Abv == b.Abv && + beer.Brewery == b.Brewery && + beer.Name == b.Name { + return fmt.Errorf("Beer already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of beers should always increase. + beer.ID = len(allBeers) + 1 + + if err := s.db.Write(collection, resource, beer); err != nil { + return err + } + } + return nil +} + +// SaveReview insert reviews +func (s *JSON) SaveReview(reviews ...models.Review) error { + for _, review := range reviews { + var resource = strconv.Itoa(review.ID) + var collection = strconv.Itoa(CollectionReview) + + beerFound, err := s.FindBeer(models.Beer{ID: review.BeerID}) + if err != nil { + return err + } + + if len(beerFound) == 0 { + return fmt.Errorf("The beer selected for the review does not exist") + } + + allReviews := s.FindReviews() + for _, r := range allReviews { + if review.BeerID == r.BeerID && + review.FirstName == r.FirstName && + review.LastName == r.LastName && + review.Text == r.Text { + return fmt.Errorf("Review already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of reviews should always increase. + review.ID = len(allReviews) + 1 + + if err = s.db.Write(collection, resource, review); err != nil { + return err + } + } + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *JSON) FindBeer(criteria models.Beer) ([]*models.Beer, error) { + var beers []*models.Beer + var beer models.Beer + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionBeer) + + if err := s.db.Read(collection, resource, &beer); err != nil { + return beers, err + } + + beers = append(beers, &beer) + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *JSON) FindReview(criteria models.Review) ([]*models.Review, error) { + var reviews []*models.Review + var review models.Review + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionReview) + + if err := s.db.Read(collection, resource, &review); err != nil { + return reviews, err + } + + reviews = append(reviews, &review) + + return reviews, nil +} + +func (s *JSON) FindBeers() []models.Beer { + var beers []models.Beer + var collection = strconv.Itoa(CollectionBeer) + + records, err := s.db.ReadAll(collection) + if err != nil { + return beers + } + + for _, b := range records { + var beer models.Beer + + if err := json.Unmarshal([]byte(b), &beer); err != nil { + return beers + } + + beers = append(beers, beer) + } + + return beers +} + +func (s *JSON) FindReviews() []models.Review { + var reviews []models.Review + var collection = strconv.Itoa(CollectionReview) + + records, err := s.db.ReadAll(collection) + if err != nil { + return reviews + } + + for _, r := range records { + var review models.Review + + if err := json.Unmarshal([]byte(r), &review); err != nil { + return reviews + } + + reviews = append(reviews, review) + } + + return reviews +} diff --git a/layered/storage/json_test.go b/layered/storage/json_test.go new file mode 100644 index 0000000..82be054 --- /dev/null +++ b/layered/storage/json_test.go @@ -0,0 +1 @@ +package storage diff --git a/layered/storage/memory.go b/layered/storage/memory.go new file mode 100644 index 0000000..cd284f6 --- /dev/null +++ b/layered/storage/memory.go @@ -0,0 +1,90 @@ +package storage + +import "github.com/katzien/structure-examples/layered/models" + +// Memory data storage layered save only in memory +type Memory struct { + cellar []models.Beer + reviews []models.Review +} + +// SaveBeer insert or update beers +func (s *Memory) SaveBeer(beers ...models.Beer) error { + for _, beer := range beers { + var err error + + beersFound, err := s.FindBeer(beer) + if err != nil { + return err + } + + if len(beersFound) == 1 { + *beersFound[0] = beer + return nil + } + + beer.ID = len(s.cellar) + 1 + s.cellar = append(s.cellar, beer) + } + + return nil +} + +// SaveReview insert or update reviews +func (s *Memory) SaveReview(reviews ...models.Review) error { + for _, review := range reviews { + var err error + + reviewsFound, err := s.FindReview(review) + if err != nil { + return err + } + + if len(reviewsFound) == 1 { + *reviewsFound[0] = review + return nil + } + + review.ID = len(s.reviews) + 1 + s.reviews = append(s.reviews, review) + } + + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *Memory) FindBeer(criteria models.Beer) ([]*models.Beer, error) { + var beers []*models.Beer + + for idx := range s.cellar { + + if s.cellar[idx].ID == criteria.ID { + beers = append(beers, &s.cellar[idx]) + } + } + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *Memory) FindReview(criteria models.Review) ([]*models.Review, error) { + var reviews []*models.Review + + for idx := range s.reviews { + if s.reviews[idx].ID == criteria.ID || s.reviews[idx].BeerID == criteria.BeerID { + reviews = append(reviews, &s.reviews[idx]) + } + } + + return reviews, nil +} + +// FindBeers return all beers +func (s *Memory) FindBeers() []models.Beer { + return s.cellar +} + +// FindReviews return all reviews +func (s *Memory) FindReviews() []models.Review { + return s.reviews +} diff --git a/layered/storage/memory_test.go b/layered/storage/memory_test.go new file mode 100644 index 0000000..d071450 --- /dev/null +++ b/layered/storage/memory_test.go @@ -0,0 +1,43 @@ +package storage + +import ( + "testing" + "time" + "github.com/katzien/structure-examples/layered/models" +) + +func TestSaveBeer(t *testing.T) { + + storage := new(Memory) + sampleBeer := models.Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + } + storage.SaveBeer(sampleBeer) + + if len(storage.cellar) == 0 { + t.Errorf("Expected sample beer to be added to storage list.") + t.FailNow() + } + + sampleBeerChange := sampleBeer + sampleBeerChange.Name = "Not a beer name" + storage.SaveBeer(sampleBeerChange) + + if len(storage.cellar) > 1 { + t.Errorf("Expected sample beer to be updated instead of creating another entry.") + t.FailNow() + } + + if storage.cellar[0].Name == sampleBeer.Name { + t.Errorf("Expected sample beer name to be updated with new name.") + t.FailNow() + } +} diff --git a/modular/Gopkg.toml b/modular/Gopkg.toml new file mode 100644 index 0000000..cec6105 --- /dev/null +++ b/modular/Gopkg.toml @@ -0,0 +1,24 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + +[[constraint]] + name = "github.com/julienschmidt/httprouter" + version = "1.1.0" diff --git a/modular/beers/beer.go b/modular/beers/beer.go new file mode 100644 index 0000000..2d4a2cb --- /dev/null +++ b/modular/beers/beer.go @@ -0,0 +1,13 @@ +package beers + +import "time" + +// Beer defines the properties of a beer +type Beer struct { + ID int `json:"id"` + Name string `json:"name"` + Brewery string `json:"brewery"` + Abv float32 `json:"abv"` + ShortDesc string `json:"short_description"` + Created time.Time `json:"created"` +} \ No newline at end of file diff --git a/modular/beers/handler.go b/modular/beers/handler.go new file mode 100644 index 0000000..92175c0 --- /dev/null +++ b/modular/beers/handler.go @@ -0,0 +1,52 @@ +package beers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/modular/database" +) + +// GetBeers returns the cellar +func GetBeers(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "application/json") + cellar := database.DB.FindBeers() + json.NewEncoder(w).Encode(cellar) +} + +// GetBeer returns a beer from the cellar +func GetBeer(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + cellar, _ := database.DB.FindBeer(Beer{ID: ID}) + if len(cellar) == 1 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cellar[0]) + return + } + + http.Error(w, "The beer you requested does not exist.", http.StatusNotFound) +} + +// AddBeer adds a new beer to the cellar +func AddBeer(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + decoder := json.NewDecoder(r.Body) + + var newBeer Beer + err := decoder.Decode(&newBeer) + if err != nil { + http.Error(w, "Bad beer - this will be a HTTP status code soon!", http.StatusBadRequest) + return + } + + database.DB.SaveBeer(newBeer) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer added.") +} diff --git a/modular/beers/handler_test.go b/modular/beers/handler_test.go new file mode 100644 index 0000000..af198ba --- /dev/null +++ b/modular/beers/handler_test.go @@ -0,0 +1,108 @@ +package beers + +//import ( +// "net/http/httptest" +// "github.com/vulcand/vulcand/router" +// "bytes" +// "fmt" +// "testing" +// "encoding/json" +// "math/rand" +// "net/http" +//) + +//func TestGetBeers(t *testing.T) { +// var cellarFromRequest []beers.Beer +// var cellarFromStorage []beers.Beer +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("GET", "/beers", nil) +// +// router.ServeHTTP(w, r) +// +// cellarFromStorage = models.DB.FindBeers() +// json.Unmarshal(w.Body.Bytes(), &cellarFromRequest) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route GET /beers to be valid.") +// t.FailNow() +// } +// +// if len(cellarFromRequest) != len(cellarFromStorage) { +// t.Error("Expected number of beers from request to be the same as beers in the storage") +// t.FailNow() +// } +// +// var mapCellar = make(map[beers.Beer]int, len(cellarFromStorage)) +// for _, beer := range cellarFromStorage { +// mapCellar[beer] = 1 +// } +// +// for _, beerResp := range cellarFromRequest { +// if _, ok := mapCellar[beerResp]; !ok { +// t.Errorf("Expected all results to match existing records") +// t.FailNow() +// break +// } +// } +//} +// +//func TestAddBeer(t *testing.T) { +// newBeer := beers.Beer{ +// Name: "Testing beer", +// Abv: 333, +// Brewery: "Testing Beer Inc", +// } +// +// newBeerJSON, err := json.Marshal(newBeer) +// if err != nil { +// t.Fatal(err) +// } +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("POST", "/beers", bytes.NewBuffer(newBeerJSON)) +// +// router.ServeHTTP(w, r) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route POST /beers to be valid.") +// t.FailNow() +// } +// +// newBeerMissing := true +// for _, b := range models.DB.FindBeers() { +// if b.Name == newBeer.Name && +// b.Abv == newBeer.Abv && +// b.Brewery == newBeer.Brewery { +// newBeerMissing = false +// } +// } +// +// if newBeerMissing { +// t.Errorf("Expected to find new entry in storage`") +// t.FailNow() +// } +//} +// +//func TestGetBeer(t *testing.T) { +// cellar := models.DB.FindBeers() +// choice := rand.Intn(len(cellar) - 1) +// +// w := httptest.NewRecorder() +// r, _ := http.NewRequest("GET", fmt.Sprintf("/beers/%d", cellar[choice].ID), nil) +// +// router.ServeHTTP(w, r) +// +// if w.Code != http.StatusOK { +// t.Errorf("Expected route GET /beers/%d to be valid.", cellar[choice].ID) +// t.FailNow() +// } +// +// var selectedBeer beers.Beer +// json.Unmarshal(w.Body.Bytes(), &selectedBeer) +// +// if cellar[choice] != selectedBeer { +// t.Errorf("Expected to match results with selected beer") +// t.FailNow() +// } +//} diff --git a/modular/database/data.go b/modular/database/data.go new file mode 100644 index 0000000..cfde1f9 --- /dev/null +++ b/modular/database/data.go @@ -0,0 +1,136 @@ +package database + +import ( + "time" + "github.com/katzien/structure-examples/modular/beers" + "github.com/katzien/structure-examples/modular/reviews" +) + +// PopulateBeers populates the Cellar variable with Beers +func PopulateBeers() { + defaultBeers := []beers.Beer{ + beers.Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 2, + Name: "Oatmeal Stout", + Brewery: "Samuel Smith", + Abv: 5, + ShortDesc: "Brewed with well water (the original well at the " + + "Old Brewery, sunk in 1758, is still in use, with the hard well " + + "water being drawn from 85 feet underground); fermented in " + + "‘stone Yorkshire squares’ to create an almost opaque, " + + "wonderfully silky and smooth textured ale with a complex " + + "medium dry palate and bittersweet finish.", + Created: time.Date(2017, time.October, 24, 22, 12, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 3, + Name: "Märzen", + Brewery: "Schlenkerla", + Abv: 5, + ShortDesc: "Bamberg's speciality, a dark, bottom fermented " + + "smokebeer, brewed with Original Schlenkerla Smokemalt from " + + "the Schlenkerla maltings and tapped according to old tradition " + + "directly from the gravity-fed oakwood cask in the historical " + + "brewery tavern.", + Created: time.Date(2017, time.October, 24, 22, 17, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 4, + Name: "Duvel", + Brewery: "Duvel Moortgat", + Abv: 9, + ShortDesc: "A Duvel is still seen as the reference among strong " + + "golden ales. Its bouquet is lively and tickles the nose with an " + + "element of citrus which even tends towards grapefruit thanks to " + + "the use of only the highest-quality hop varieties.", + Created: time.Date(2017, time.October, 24, 22, 24, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 5, + Name: "Negra", + Brewery: "Modelo", + Abv: 5, + ShortDesc: "Brewed longer to enhance the flavors, this Munich " + + "Dunkel-style Lager gives way to a rich flavor and remarkably " + + "smooth taste.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 6, + Name: "Guinness Draught", + Brewery: "Guinness Ltd.", + Abv: 4, + ShortDesc: "Pours dark brown, almost black with solid lasting light brown head. " + + "Aroma of bitter cocoa, light coffee and roasted malt. " + + "Body is light sweet, medium bitter. " + + "Body is light to medium, texture almost thin and carbonation average. " + + "Finish is medium bitter cocoa with more pronounced roast flavor. Smooth drinker.", + Created: time.Date(2017, time.October, 24, 22, 27, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 7, + Name: "XX Lager", + Brewery: "Cuahutemoc Moctezuma", + Abv: 4.2, + ShortDesc: "A crisp, refreshing, light-bodied malt-flavored beer with a well-balanced finish. " + + "A Lager that drinks like a Pilsner. A liquid embodiment of living life to the fullest. " + + "A beverage made from pure spring water and the choicest hops. A beer with such good taste, it’s chosen you to drink it.", + Created: time.Date(2017, time.October, 28, 15, 02, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 8, + Name: "Tecate", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "Very smooth, medium bodied brew. Malt sweetness is thin, and can be likened to diluted sugar water. " + + "Touch of fructose-like sweetness. Light citric hop flavours gently prick the palate with tea-like notes that follow and fade quickly. " + + "Finishes a bit dry with husk tannins and a pasty mouthfeel.", + Created: time.Date(2017, time.October, 28, 15, 07, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 9, + Name: "Sol", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "While Corona wins the marketing wars in the U.S., Sol is the winning brand in much of Mexico, despite not being a standout in any respect. " + + "You see the logo plastered everywhere and it’s seemingly on every restaurant and bar menu. Like Corona, it’s simple and inoffensive, " + + "but still slightly more flavorful than your typical American macrobrew. At its best ice cold, and progressively worse as it gets warmer.", + Created: time.Date(2017, time.October, 28, 15, 12, 0, 0, time.UTC), + }, + beers.Beer{ + ID: 10, + Name: "Corona", + Brewery: "Cuahutemoc Moctezuma", + Abv: 5, + ShortDesc: "One of the five best-selling beers in the world, but it usually tastes better in Mexico, " + + "where the bottles don’t have so much time in transit and on shelves. (Sunlight coming through clear bottles is never a good thing for beer.) " + + "This is the typical “drink all afternoon” beer, working well on its own or with a plate of tacos. Refreshing with a lime.", + Created: time.Date(2017, time.October, 28, 15, 14, 0, 0, time.UTC), + }, + } + DB.SaveBeer(defaultBeers...) +} + +// PopulateReviews populates the Reviews variable with Reviews +func PopulateReviews() { + defaultReviews := []reviews.Review{ + reviews.Review{ID: 1, BeerID: 1, FirstName: "Joe", LastName: "Tribiani", Score: 5, Text: "This is good but this is not pizza!", Created: time.Date(2017, time.November, 10, 12, 36, 0, 0, time.UTC)}, + reviews.Review{ID: 2, BeerID: 2, FirstName: "Chandler", LastName: "Bing", Score: 1, Text: "I would SO NOT drink this ever again.", Created: time.Date(2017, time.October, 25, 5, 55, 0, 0, time.UTC)}, + reviews.Review{ID: 3, BeerID: 1, FirstName: "Ross", LastName: "Geller", Score: 4, Text: "Drank while on a break, was pretty good!", Created: time.Date(2017, time.October, 25, 12, 3, 0, 0, time.UTC)}, + reviews.Review{ID: 4, BeerID: 2, FirstName: "Phoebe", LastName: "Buffay", Score: 2, Text: "Wasn't that great, so I gave it to my smelly cat.", Created: time.Date(2017, time.October, 21, 16, 45, 0, 0, time.UTC)}, + reviews.Review{ID: 5, BeerID: 1, FirstName: "Monica", LastName: "Geller", Score: 5, Text: "AMAZING! Like Chandler's jokes!", Created: time.Date(2017, time.October, 22, 13, 41, 0, 0, time.UTC)}, + reviews.Review{ID: 6, BeerID: 2, FirstName: "Rachel", LastName: "Green", Score: 5, Text: "So yummy, just like my beef and custard trifle.", Created: time.Date(2017, time.October, 17, 9, 12, 0, 0, time.UTC)}, + } + DB.SaveReview(defaultReviews...) +} diff --git a/modular/database/database.go b/modular/database/database.go new file mode 100644 index 0000000..6cfeaff --- /dev/null +++ b/modular/database/database.go @@ -0,0 +1,45 @@ +package database + +import "github.com/katzien/structure-examples/modular/beers" +import "github.com/katzien/structure-examples/modular/reviews" + +// StorageType defines available storage types +type StorageType int + +const ( + // JSON will store data in JSON files saved on disk + JSON StorageType = iota + // Memory will store data in memory + Memory +) + +// DB is an interface to interact with data on multiple layered of data storage +var DB Storage + +// Storage represents all possible actions available to deal with data +type Storage interface { + SaveBeer(...beers.Beer) error + SaveReview(...reviews.Review) error + FindBeer(beers.Beer) ([]*beers.Beer, error) + FindReview(reviews.Review) ([]*reviews.Review, error) + FindBeers() []beers.Beer + FindReviews() []reviews.Review +} + +func NewStorage(storageType StorageType) error { + var err error + + switch storageType { + case Memory: + DB = new(MemoryStorage) + + case JSON: + // for the moment storage location for JSON files is the current working directory + DB, err = NewJSONStorage("./data/") + if err != nil { + return err + } + } + + return nil +} diff --git a/modular/database/database_test.go b/modular/database/database_test.go new file mode 100644 index 0000000..636bab8 --- /dev/null +++ b/modular/database/database_test.go @@ -0,0 +1 @@ +package database diff --git a/modular/database/json.go b/modular/database/json.go new file mode 100644 index 0000000..2255aeb --- /dev/null +++ b/modular/database/json.go @@ -0,0 +1,174 @@ +package database + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/nanobox-io/golang-scribble" + "github.com/katzien/structure-examples/modular/beers" + "github.com/katzien/structure-examples/modular/reviews" +) + +const ( + // CollectionBeer identifier for JSON collection about beers + CollectionBeer int = iota + // CollectionReview identifier for JSON collection about reviews + CollectionReview +) + +// JSON is the data storage layered using JSON file +type JSONStorage struct { + db *scribble.Driver +} + +func NewJSONStorage(location string) (*JSONStorage, error) { + var err error + + stg := new(JSONStorage) + + stg.db, err = scribble.New(location, nil) + if err != nil { + return nil, err + } + + return stg, nil +} + +// SaveBeer insert new beers +func (s *JSONStorage) SaveBeer(beers ...beers.Beer) error { + for _, beer := range beers { + var resource = strconv.Itoa(beer.ID) + var collection = strconv.Itoa(CollectionBeer) + + allBeers := s.FindBeers() + for _, b := range allBeers { + if beer.Abv == b.Abv && + beer.Brewery == b.Brewery && + beer.Name == b.Name { + return fmt.Errorf("Beer already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of beers should always increase. + beer.ID = len(allBeers) + 1 + + if err := s.db.Write(collection, resource, beer); err != nil { + return err + } + } + return nil +} + +// SaveReview insert reviews +func (s *JSONStorage) SaveReview(reviews ...reviews.Review) error { + for _, review := range reviews { + var resource = strconv.Itoa(review.ID) + var collection = strconv.Itoa(CollectionReview) + + beerFound, err := s.FindBeer(beers.Beer{ID: review.BeerID}) + if err != nil { + return err + } + + if len(beerFound) == 0 { + return fmt.Errorf("The beer selected for the review does not exist") + } + + allReviews := s.FindReviews() + for _, r := range allReviews { + if review.BeerID == r.BeerID && + review.FirstName == r.FirstName && + review.LastName == r.LastName && + review.Text == r.Text { + return fmt.Errorf("Review already exists") + } + } + + // TODO: Since delete function has not been implemented yet + // I think we can assume size of reviews should always increase. + review.ID = len(allReviews) + 1 + + if err = s.db.Write(collection, resource, review); err != nil { + return err + } + } + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *JSONStorage) FindBeer(criteria beers.Beer) ([]*beers.Beer, error) { + var beers []*beers.Beer + var beer beers.Beer + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionBeer) + + if err := s.db.Read(collection, resource, &beer); err != nil { + return beers, err + } + + beers = append(beers, &beer) + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *JSONStorage) FindReview(criteria reviews.Review) ([]*reviews.Review, error) { + var reviews []*reviews.Review + var review reviews.Review + var resource = strconv.Itoa(criteria.ID) + var collection = strconv.Itoa(CollectionReview) + + if err := s.db.Read(collection, resource, &review); err != nil { + return reviews, err + } + + reviews = append(reviews, &review) + + return reviews, nil +} + +func (s *JSONStorage) FindBeers() []beers.Beer { + var beers []beers.Beer + var collection = strconv.Itoa(CollectionBeer) + + records, err := s.db.ReadAll(collection) + if err != nil { + return beers + } + + for _, b := range records { + var beer beers.Beer + + if err := json.Unmarshal([]byte(b), &beer); err != nil { + return beers + } + + beers = append(beers, beer) + } + + return beers +} + +func (s *JSONStorage) FindReviews() []reviews.Review { + var reviews []reviews.Review + var collection = strconv.Itoa(CollectionReview) + + records, err := s.db.ReadAll(collection) + if err != nil { + return reviews + } + + for _, r := range records { + var review reviews.Review + + if err := json.Unmarshal([]byte(r), &review); err != nil { + return reviews + } + + reviews = append(reviews, review) + } + + return reviews +} diff --git a/modular/database/json_test.go b/modular/database/json_test.go new file mode 100644 index 0000000..636bab8 --- /dev/null +++ b/modular/database/json_test.go @@ -0,0 +1 @@ +package database diff --git a/modular/database/memory.go b/modular/database/memory.go new file mode 100644 index 0000000..effde93 --- /dev/null +++ b/modular/database/memory.go @@ -0,0 +1,93 @@ +package database + +import ( + "github.com/katzien/structure-examples/modular/beers" + "github.com/katzien/structure-examples/modular/reviews" +) + +// Memory data storage layered save only in memory +type MemoryStorage struct { + cellar []beers.Beer + reviews []reviews.Review +} + +// SaveBeer insert or update beers +func (s *MemoryStorage) SaveBeer(beers ...beers.Beer) error { + for _, beer := range beers { + var err error + + beersFound, err := s.FindBeer(beer) + if err != nil { + return err + } + + if len(beersFound) == 1 { + *beersFound[0] = beer + return nil + } + + beer.ID = len(s.cellar) + 1 + s.cellar = append(s.cellar, beer) + } + + return nil +} + +// SaveReview insert or update reviews +func (s *MemoryStorage) SaveReview(reviews ...reviews.Review) error { + for _, review := range reviews { + var err error + + reviewsFound, err := s.FindReview(review) + if err != nil { + return err + } + + if len(reviewsFound) == 1 { + *reviewsFound[0] = review + return nil + } + + review.ID = len(s.reviews) + 1 + s.reviews = append(s.reviews, review) + } + + return nil +} + +// FindBeer locate full data set based on given criteria +func (s *MemoryStorage) FindBeer(criteria beers.Beer) ([]*beers.Beer, error) { + var beers []*beers.Beer + + for idx := range s.cellar { + + if s.cellar[idx].ID == criteria.ID { + beers = append(beers, &s.cellar[idx]) + } + } + + return beers, nil +} + +// FindReview locate full data set based on given criteria +func (s *MemoryStorage) FindReview(criteria reviews.Review) ([]*reviews.Review, error) { + var reviews []*reviews.Review + + for idx := range s.reviews { + if s.reviews[idx].ID == criteria.ID || s.reviews[idx].BeerID == criteria.BeerID { + reviews = append(reviews, &s.reviews[idx]) + } + } + + return reviews, nil +} + +// FindBeers return all beers +func (s *MemoryStorage) FindBeers() []beers.Beer { + return s.cellar +} + +// FindReviews return all reviews +func (s *MemoryStorage) FindReviews() []reviews.Review { + return s.reviews +} diff --git a/modular/database/memory_test.go b/modular/database/memory_test.go new file mode 100644 index 0000000..7dc1cf7 --- /dev/null +++ b/modular/database/memory_test.go @@ -0,0 +1,43 @@ +package database + +import ( + "testing" + "time" + "github.com/katzien/structure-examples/modular/beers" +) + +func TestSaveBeer(t *testing.T) { + + storage := new(MemoryStorage) + sampleBeer := beers.Beer{ + ID: 1, + Name: "Pliny the Elder", + Brewery: "Russian River Brewing Company", + Abv: 8, + ShortDesc: "Pliny the Elder is brewed with Amarillo, " + + "Centennial, CTZ, and Simcoe hops. It is well-balanced with " + + "malt, hops, and alcohol, slightly bitter with a fresh hop " + + "aroma of floral, citrus, and pine.", + Created: time.Date(2017, time.October, 24, 22, 6, 0, 0, time.UTC), + } + storage.SaveBeer(sampleBeer) + + if len(storage.cellar) == 0 { + t.Errorf("Expected sample beer to be added to storage list.") + t.FailNow() + } + + sampleBeerChange := sampleBeer + sampleBeerChange.Name = "Not a beer name" + storage.SaveBeer(sampleBeerChange) + + if len(storage.cellar) > 1 { + t.Errorf("Expected sample beer to be updated instead of creating another entry.") + t.FailNow() + } + + if storage.cellar[0].Name == sampleBeer.Name { + t.Errorf("Expected sample beer name to be updated with new name.") + t.FailNow() + } +} diff --git a/modular/main.go b/modular/main.go new file mode 100644 index 0000000..1bb84c3 --- /dev/null +++ b/modular/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/modular/database" + "github.com/katzien/structure-examples/modular/beers" + "github.com/katzien/structure-examples/modular/reviews" +) + +var router *httprouter.Router + +func init() { + var err error + + err = database.NewStorage(database.Memory) + if err != nil { + log.Fatal(err) + } + + database.PopulateBeers() + database.PopulateReviews() + + router = httprouter.New() + + router.GET("/beers", beers.GetBeers) + router.GET("/beers/:id", beers.GetBeer) + router.GET("/beers/:id/reviews", reviews.GetBeerReviews) + + router.POST("/beers", beers.AddBeer) + router.POST("/beers/:id/reviews", reviews.AddBeerReview) +} + +func main() { + fmt.Println("The beer server is on tap now.") + log.Fatal(http.ListenAndServe(":8080", router)) +} diff --git a/modular/reviews/handler.go b/modular/reviews/handler.go new file mode 100644 index 0000000..fa7bce4 --- /dev/null +++ b/modular/reviews/handler.go @@ -0,0 +1,51 @@ +package reviews + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" + "github.com/katzien/structure-examples/modular/database" +) + +// GetBeerReviews returns all reviews for a beer +func GetBeerReviews(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + // TODO: Consider checking if a beer matching the ID actually exists, and + // 404 if that is not the case. + + results, _ := database.DB.FindReview(Review{BeerID: ID}) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +// AddBeerReview adds a new review for a beer +func AddBeerReview(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ID, err := strconv.Atoi(ps.ByName("id")) + if err != nil { + http.Error(w, fmt.Sprintf("%s is not a valid Beer ID, it must be a number.", ps.ByName("id")), http.StatusBadRequest) + return + } + + var newReview Review + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&newReview); err != nil { + http.Error(w, "Failed to parse review", http.StatusBadRequest) + } + + newReview.BeerID = ID + if err := database.DB.SaveReview(newReview); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode("New beer review added.") + +} diff --git a/modular/reviews/handler_test.go b/modular/reviews/handler_test.go new file mode 100644 index 0000000..248efc3 --- /dev/null +++ b/modular/reviews/handler_test.go @@ -0,0 +1 @@ +package reviews diff --git a/modular/reviews/review.go b/modular/reviews/review.go new file mode 100644 index 0000000..6fd16a3 --- /dev/null +++ b/modular/reviews/review.go @@ -0,0 +1,14 @@ +package reviews + +import "time" + +// Review defines the properties of a beer review +type Review struct { + ID string `json:"id"` + BeerID int `json:"beer_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Score int `json:"score"` + Text string `json:"text"` + Created time.Time `json:"created"` +}