diff --git a/.env-template b/.env-template index 8d53c2a..30fe135 100644 --- a/.env-template +++ b/.env-template @@ -1,4 +1,7 @@ #GODEBUG=gctrace=1 JOKE_SERVICE_URL=https://api.chucknorris.io/jokes/random -PORT=3000 +JOKE_SERVICE_PORT=3000 +MONGO_HOST=mongodb://localhost:27017 +MONGO_DATABASE=template +MONGO_COLLECTION=beer diff --git a/.realize.yaml b/.realize.yaml index a4bad3f..1530e86 100644 --- a/.realize.yaml +++ b/.realize.yaml @@ -13,10 +13,10 @@ schema: commands: install: status: true - dir: cmd + method: go build -o output/app ./cmd run: status: true - method: cmd + method: output/app watcher: paths: - / diff --git a/build/Dockerfile.dev b/build/Dockerfile.dev index 0ddf297..b76f25d 100644 --- a/build/Dockerfile.dev +++ b/build/Dockerfile.dev @@ -10,9 +10,8 @@ RUN go get -u github.com/mgechev/revive # realize: watcher and live reloading RUN go get github.com/oxequa/realize -WORKDIR /go/src/ctco-dev/$PROJECT -# enable Go Modules -ENV GO111MODULE=on +WORKDIR /app/src/ctco-dev/$PROJECT + # no CGO (see https://github.com/golang/go/issues/28065) ENV CGO_ENABLED=0 diff --git a/cmd/main.go b/cmd/main.go index dd026e2..4648df3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -24,8 +24,8 @@ func main() { log.WithCtx(rootCtx).Panicf("env vars error: '%v'", err) } - someApp := app.New(env) - addr := fmt.Sprintf(":%d", env.Port) + someApp := app.New(rootCtx, env) + addr := fmt.Sprintf(":%d", env.JokeServicePort) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqID := uuid.NewV4().String()[0:8] reqCtx := log.NewContext(rootCtx, logrus.Fields{"reqID": reqID}) @@ -34,6 +34,6 @@ func main() { someApp.ServeHTTP(w, r.WithContext(reqCtx)) }) - log.WithCtx(rootCtx).Infof("Server is running at: http://localhost:%d", env.Port) + log.WithCtx(rootCtx).Infof("Server is running at: http://localhost:%d", env.JokeServicePort) log.WithCtx(rootCtx).Fatal(http.ListenAndServe(addr, handler)) } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f2d9dad..4678ea1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,6 +9,16 @@ services: - ./cmd:/go/src/ctco-dev/go-api-template/cmd ports: - 3000:3000 + depends_on: + - mongo environment: - JOKE_SERVICE_URL + - JOKE_SERVICE_PORT + - MONGO_HOST=mongodb://mongo:27017 + - MONGO_DATABASE + - MONGO_COLLECTION tty: true + mongo: + image: mongo + volumes: + - /data/db \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f30006e..99bfb8f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,8 @@ services: - 3000:3000 environment: - JOKE_SERVICE_URL + - JOKE_SERVICE_PORT + - MONGO_HOST + - MONGO_DATABASE + - MONGO_COLLECTION tty: true diff --git a/go.mod b/go.mod index 8c01d6e..51b94ce 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,21 @@ module github.com/ctco-dev/go-api-template require ( + github.com/go-stack/stack v1.8.0 // indirect + github.com/golang/snappy v0.0.1 // indirect + github.com/google/go-cmp v0.2.0 // indirect github.com/kelseyhightower/envconfig v1.3.0 github.com/kr/pretty v0.1.0 // indirect github.com/pkg/errors v0.8.1 github.com/satori/go.uuid v1.2.0 github.com/sirupsen/logrus v1.1.0 + github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51 // indirect + github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c // indirect + github.com/xdg/stringprep v1.0.0 // indirect + go.mongodb.org/mongo-driver v1.0.0 golang.org/x/crypto v0.0.0-20180927165925-5295e8364332 // indirect + golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 // indirect golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611 // indirect + golang.org/x/text v0.3.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 3dcaa9f..1a1d616 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,42 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM= github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe h1:CHRGQ8V7OlCYtwaKPJi3iA7J+YdNKdo8j7nG5IgDhjs= github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mongodb/mongo-go-driver v1.0.0 h1:aq055NT+Xu6ta/f7D51gIbLHIZwM0Gwzt9RHfmrzs6A= +github.com/mongodb/mongo-go-driver v1.0.0/go.mod h1:NK/HWDIIZkaYsnYa0hmtP443T5ELr0KDecmIioVuuyU= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sirupsen/logrus v1.1.0 h1:65VZabgUiV9ktjGM5nTq0+YurgTyX+YI2lSSfDjI+qU= github.com/sirupsen/logrus v1.1.0/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +go.mongodb.org/mongo-driver v0.3.0 h1:DASSc8s5ayctU+AWYPnCTXFWevhMA3HblGxGK2HICQA= +go.mongodb.org/mongo-driver v0.3.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.0.0 h1:KxPRDyfB2xXnDE2My8acoOWBQkfv3tz0SaWTRZjJR0c= +go.mongodb.org/mongo-driver v1.0.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180927165925-5295e8364332 h1:hvQVdF6P9DX4OiKA5tpehlG6JsgzmyQiThG7q5Bn3UQ= golang.org/x/crypto v0.0.0-20180927165925-5295e8364332/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611 h1:O33LKL7WyJgjN9CvxfTIomjIClbd/Kq86/iipowHQU0= golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/app.go b/internal/app/app.go index 9ac9ace..043e63e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,39 +1,52 @@ package app import ( + "context" "net/http" "path" "strings" + mongobeer "github.com/ctco-dev/go-api-template/internal/db" + "github.com/ctco-dev/go-api-template/internal/joke" ) //Specification is a container of app config parameters type Specification struct { - JokeServiceURL string `split_words:"true" required:"true" default:"https://api.chucknorris.io/jokes/random"` - Port int `split_words:"true" required:"true" default:"3000"` + JokeServiceURL string `split_words:"true" required:"true" default:"https://api.chucknorris.io/jokes/random"` + JokeServicePort int `split_words:"true" required:"true" default:"3000"` + MongoHost string `split_words:"true" required:"true" default:"mongodb://localhost:27017"` + MongoDatabase string `split_words:"true" required:"true" default:"template"` + MongoCollection string `split_words:"true" required:"true" default:"beer"` } // App implements a sample http service type app struct { jokeHandler *jokeHandler + beerHandler *beerHandler } // New creates a new application -func New(env Specification) http.Handler { - return &app{jokeHandler: &jokeHandler{ - client: joke.NewChuckNorrisAPIClient(env.JokeServiceURL), - }} +func New(ctx context.Context, env Specification) http.Handler { + return &app{ + jokeHandler: &jokeHandler{client: joke.NewChuckNorrisAPIClient(env.JokeServiceURL)}, + beerHandler: &beerHandler{ + repository: mongobeer.NewRepo(ctx, env.MongoHost, env.MongoDatabase, env.MongoCollection), + }, + } } func (a *app) ServeHTTP(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = ShiftPath(r.URL.Path) - if head == "joke" { + switch head { + case "joke": a.jokeHandler.ServeHTTP(w, r) - return + case "beer": + a.beerHandler.ServeHTTP(w, r) + default: + http.Error(w, "Not Found", http.StatusNotFound) } - http.Error(w, "Not Found", http.StatusNotFound) } // ShiftPath splits off the first component of p, which will be cleaned of diff --git a/internal/app/beer-handler.go b/internal/app/beer-handler.go new file mode 100644 index 0000000..2ab8e8f --- /dev/null +++ b/internal/app/beer-handler.go @@ -0,0 +1,129 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/ctco-dev/go-api-template/internal/beer" + + "github.com/ctco-dev/go-api-template/internal/log" +) + +// JokeHandler handles /joke route +type beerHandler struct { + repository beer.Repository +} + +func (b *beerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var head string + head, _ = ShiftPath(r.URL.Path) + + if head != "" { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + switch r.Method { + case "GET": + b.handleGet(w, r) + case "PUT": + b.handlePut(w, r) + case "DELETE": + b.handleDelete(w, r) + default: + http.Error(w, "Only GET, PUT and DELETE are allowed", http.StatusMethodNotAllowed) + } +} + +func (b *beerHandler) handleGet(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ids, ok := r.URL.Query()["id"] + + if !ok || len(ids[0]) < 1 { + getAllBeers(ctx, w, b.repository) + return + } + + getOneBeer(ctx, w, ids[0], b.repository) +} + +func (b *beerHandler) handlePut(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + names, ok := r.URL.Query()["name"] + + if !ok || len(names[0]) < 1 { + http.Error(w, "Beer name is missing.", http.StatusInternalServerError) + return + } + + writeBeer(ctx, w, names[0], b.repository) +} + +func (b *beerHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ids, ok := r.URL.Query()["id"] + + if !ok || len(ids[0]) < 1 { + http.Error(w, "Beer id is missing.", http.StatusInternalServerError) + return + } + + removeBeer(ctx, w, ids[0], b.repository) +} + +func getOneBeer(ctx context.Context, w http.ResponseWriter, id string, reader beer.Reader) { + beer, err := reader.Read(ctx, id) + if err != nil { + http.Error(w, "Can't get a beer.", http.StatusInternalServerError) + return + } + + writeResult(ctx, w, beer) +} + +func getAllBeers(ctx context.Context, w http.ResponseWriter, reader beer.AllReader) { + beers, err := reader.ReadAll(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeResult(ctx, w, beers) +} + +func writeBeer(ctx context.Context, w http.ResponseWriter, name string, writer beer.Writer) { + id, err := writer.Write(ctx, beer.Beer{Name: name}) + if err != nil { + http.Error(w, "Can't write a beer.", http.StatusInternalServerError) + return + } + + writeResult(ctx, w, id) +} + +func removeBeer(ctx context.Context, w http.ResponseWriter, id string, remover beer.Remover) { + err := remover.Remove(ctx, id) + if err != nil { + http.Error(w, "Can't delete a beer.", http.StatusInternalServerError) + return + } + + writeResult(ctx, w, "success") +} + +func writeResult(ctx context.Context, w http.ResponseWriter, data interface{}) { + bytes, err := json.Marshal(data) + if err != nil { + http.Error(w, "Can't encode response.", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(bytes) + log.WithCtx(ctx).Info("I'm done") +} diff --git a/internal/app/joke-handler.go b/internal/app/joke-handler.go index f04a14e..b5e078f 100644 --- a/internal/app/joke-handler.go +++ b/internal/app/joke-handler.go @@ -41,7 +41,7 @@ func (j *jokeHandler) handleGet(w http.ResponseWriter, r *http.Request) { log.WithCtx(ctx).Info("I'm trying to get new joke") jokeResp, err := j.client.GetJoke(ctx) if err != nil { - http.Error(w, "Can't get error", http.StatusInternalServerError) + http.Error(w, "Can't get a joke", http.StatusInternalServerError) return } diff --git a/internal/beer/beer.go b/internal/beer/beer.go new file mode 100644 index 0000000..e9ed7ca --- /dev/null +++ b/internal/beer/beer.go @@ -0,0 +1,42 @@ +package beer + +import ( + "context" +) + +// ID is string alias type for User id +type ID = string + +// Beer represents a beer +type Beer struct { + ID ID + Name string +} + +// Reader reads beer by id +type Reader interface { + Read(context.Context, ID) (*Beer, error) +} + +// AllReader reads all beers +type AllReader interface { + ReadAll(context.Context) ([]*Beer, error) +} + +// Writer writes a new beer and returns its id +type Writer interface { + Write(context.Context, Beer) (ID, error) +} + +// Remover removes a beer by id +type Remover interface { + Remove(context.Context, ID) error +} + +// Repo is an interface for a beer repository +type Repository interface { + Reader + AllReader + Writer + Remover +} diff --git a/internal/db/mongobeer.go b/internal/db/mongobeer.go new file mode 100644 index 0000000..64327a0 --- /dev/null +++ b/internal/db/mongobeer.go @@ -0,0 +1,117 @@ +package mongobeer + +import ( + "context" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/ctco-dev/go-api-template/internal/beer" + "github.com/ctco-dev/go-api-template/internal/log" +) + +type beerModel struct { + ID primitive.ObjectID `bson:"_id"` + Name string `bson:"name"` +} + +type repo struct { + collection *mongo.Collection +} + +// NewRepo returns a new mongodb beer repository +func NewRepo(ctx context.Context, host string, db string, collection string) beer.Repository { + client, err := mongo.NewClient(options.Client().ApplyURI(host)) + if err != nil { + log.WithCtx(ctx).Panic(err) + } + err = client.Connect(ctx) + if err != nil { + log.WithCtx(ctx).Panic(err) + } + + err = client.Ping(ctx, nil) + + if err != nil { + log.WithCtx(ctx).Panic(err) + } + + log.WithCtx(ctx).Info("Connected to MongoDB!") + return &repo{collection: client.Database(db).Collection(collection)} +} + +func (r *repo) Read(ctx context.Context, id beer.ID) (*beer.Beer, error) { + log.WithCtx(ctx).Info("Reading a beer") + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + + var res beerModel + filter := bson.D{{"_id", oid}} + err = r.collection.FindOne(ctx, filter).Decode(&res) + if err != nil { + return nil, err + } + + return &beer.Beer{ + ID: res.ID.Hex(), + Name: res.Name, + }, nil +} + +func (r *repo) ReadAll(ctx context.Context) ([]*beer.Beer, error) { + log.WithCtx(ctx).Info("Reading all beers") + cur, err := r.collection.Find(ctx, bson.D{}) + if err != nil { + return nil, err + } + + defer cur.Close(ctx) + + var res []*beer.Beer + + for cur.Next(ctx) { + var elem beerModel + err = cur.Decode(&elem) + if err != nil { + return nil, err + } + + res = append(res, &beer.Beer{ + ID: elem.ID.Hex(), + Name: elem.Name, + }) + } + + return res, nil +} + +func (r *repo) Write(ctx context.Context, beer beer.Beer) (beer.ID, error) { + log.WithCtx(ctx).Info("Writing a beer") + document := bson.D{ + {"name", beer.Name}, + } + + res, err := r.collection.InsertOne(ctx, document) + if err != nil { + return "", err + } + + return res.InsertedID.(primitive.ObjectID).Hex(), nil +} + +func (r *repo) Remove(ctx context.Context, id beer.ID) error { + log.WithCtx(ctx).Info("Removing a beer") + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return err + } + + filter := bson.D{{"_id", oid}} + _, err = r.collection.DeleteOne(ctx, filter) + + return err +}