diff --git a/.github/workflows/supermarket-api-ci.yml b/.github/workflows/supermarket-api-ci.yml new file mode 100644 index 0000000..2041aab --- /dev/null +++ b/.github/workflows/supermarket-api-ci.yml @@ -0,0 +1,17 @@ +name: Supermarket-API CI Pipeline +on: + pull_request: + branches: + - main + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v1 + with: + go-version: 1.x + - run: | + export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14 + make unit-test diff --git a/.gitignore b/.gitignore index 66fd13c..86b7ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ *.out # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ + +# Environment file +.env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16844f2 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +dev: + docker-compose up + +unit-test: + go test -short ./... + +integration-test: + go test -integration ./... + +all-test: + go test ./... + +build: + go build -o supermarket-api cmd/api/*.go + +run: + go run cmd/api/*.go + +clean: + rm ./supermarket-api diff --git a/README.md b/README.md index dba6f9e..2129ba9 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# supermarket-api \ No newline at end of file +# supermarket-api + +Supermarket-API is a service providing functionality for cataloging produce and their prices. + +## Getting Started + +To start `supermarket-api` you will need to create an `.env` file at `cmd/api/.env`. A convenience example with sane defaults is located at `cmd/api/example.env` and you can create your environment file by running: + +``` +~$ cp cmd/api/example.env cmd/api/.env +``` + +Supermarket-API has a Makefile with commonly ran commands. To use the Makefile append the command to `make` in your terminal: + +``` +~$ make run +``` diff --git a/cmd/api/api b/cmd/api/api new file mode 100755 index 0000000..fb4fea5 Binary files /dev/null and b/cmd/api/api differ diff --git a/cmd/api/env.go b/cmd/api/env.go new file mode 100644 index 0000000..5842222 --- /dev/null +++ b/cmd/api/env.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" +) + +type config struct { + Env string `default:"dev"` + APIPort int `default:3000` + LogLevel string `default:"debug"` +} + +func load() (cfg config, err error) { + _ = godotenv.Load("cmd/api/.env") + err = envconfig.Process("", &cfg) + return +} diff --git a/cmd/api/example.env b/cmd/api/example.env new file mode 100644 index 0000000..7cc55c0 --- /dev/null +++ b/cmd/api/example.env @@ -0,0 +1,3 @@ +ENV: dev +APIPORT: 3000 +LOGLEVEL: debug diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9112c97 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/davidlick/supermarket-api/internal/http" + "github.com/sirupsen/logrus" +) + +var ( + cfg config + logger *logrus.Logger + err error +) + +func init() { + cfg, err = load() + if err != nil { + log.Fatal(err) + } + + ll, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + log.Fatal(err) + } + + logger = logrus.New() + logger.SetLevel(ll) +} + +func main() { + server := http.NewServer(cfg.APIPort, logger, cfg.Env) + + // Allow app to listen for OS Interrupts and SIGTERMS. + serverErrors := make(chan error, 1) + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM) + + go func() { + serverErrors <- server.Run() + }() + + // Handling for server errors and OS signals. + select { + case err := <-serverErrors: + log.Fatal("error starting server: %w", err.Error()) + case <-osSignals: + log.Println("starting server shutdown...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := server.Shutdown(ctx) + if err != nil { + log.Fatalf("error shutting down http server: %v", err.Error()) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cdcc367 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/davidlick/supermarket-api + +go 1.16 + +require ( + github.com/go-chi/chi v1.5.4 // indirect + github.com/joho/godotenv v1.3.0 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e4249aa --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/http/errors.go b/internal/http/errors.go new file mode 100644 index 0000000..d637369 --- /dev/null +++ b/internal/http/errors.go @@ -0,0 +1,8 @@ +package http + +import "errors" + +var ( + ErrUnknownError = errors.New("unknown error occurred") + ErrUnrecognizedCode = errors.New("unrecognized status code") +) diff --git a/internal/http/health.go b/internal/http/health.go new file mode 100644 index 0000000..808137e --- /dev/null +++ b/internal/http/health.go @@ -0,0 +1,13 @@ +package http + +import "net/http" + +func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + return +} + +func (s *server) handleReadiness(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + return +} diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 0000000..c3488fa --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,80 @@ +package http + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/sirupsen/logrus" +) + +// server holds configurations and services used by the HTTP server and handlers. +type server struct { + port int + logger *logrus.Logger + environment string + server *http.Server +} + +// NewServer initializes a new server with the required configurations. +func NewServer(port int, logger *logrus.Logger, environment string) *server { + return &server{ + port: port, + logger: logger, + environment: environment, + server: &http.Server{ + Addr: fmt.Sprintf(":%d", port), + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + }, + } +} + +// Run builds the routes and starts the server listening on the configured port. +func (s *server) Run() error { + s.server.Handler = s.buildRoutes() + s.logger.Infof("starting server on port: %d", s.port) + return s.server.ListenAndServe() +} + +// Shutdown calls Shutdown on the http.Server which attempts to gracefully shutdown. If the context deadline is exceeded any cotnext errors are returned. +func (s *server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +// buildRoutes configures a router with middleware, headers, and routes. +func (s *server) buildRoutes() http.Handler { + r := s.configureRouter() + + r.Get("/health", s.handleHealth) + r.Get("/ready", s.handleReadiness) + + r.Group(func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + // r.Group(s.produceGroup) + }) + }) + + return r +} + +// configureRouter returns a configured chi.Router with sane defaults. +func (s *server) configureRouter() chi.Router { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(setResponseHeaders(map[string]string{ + "Content-Type": "application/json", + "Allow-Access-Control-Origin": "*", + "Allow-Access-Control-Method": "OPTIONS, GET, POST, DELETE", + "Allow-Access-Control-Headers": "Origin, Content-Type, Accept", + "Access-Control-Max-Age": "600", + })) + + return r +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..7736ce9 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,16 @@ +package http + +import "net/http" + +// setResponseHeaders is a middleware that accepts a map[string]string of headers:values and sets them for all responses. +func setResponseHeaders(headers map[string]string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for header, value := range headers { + w.Header().Set(header, value) + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/response.go b/internal/http/response.go new file mode 100644 index 0000000..1e4d176 --- /dev/null +++ b/internal/http/response.go @@ -0,0 +1,43 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" +) + +func (s *server) writeSuccess(ctx context.Context, w http.ResponseWriter, data interface{}, status int) error { + if status != 0 { + w.WriteHeader(status) + } + + if data != nil { + err := json.NewEncoder(w).Encode(data) + if err != nil { + return err + } + } + + return nil +} + +func (s *server) writeError(ctx context.Context, w http.ResponseWriter, err error, code int) error { + if http.StatusText(code) == "" { + return ErrUnrecognizedCode + } + + if err == nil { + err = ErrUnknownError + } + + s.logger.Errorf("request failed: %v", err.Error()) + w.WriteHeader(code) + + res := struct { + Message string `json:"message"` + }{ + Message: http.StatusText(code), + } + + return json.NewEncoder(w).Encode(res) +}