diff --git a/.golangci.yaml b/.golangci.yaml index 14f8ebf..d91c1c4 100755 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -27,22 +27,32 @@ linters: enable: - asciicheck - bodyclose + - cyclop - deadcode + - decorder + - depguard - depguard - dogsled - dupl + - dupl - errcheck + - errname + - errorlint - exhaustive - funlen + - go-critic + - gochecknoglobals - gochecknoinits + - gocognit + - gocognit + - goconst - goconst - gocritic - - gocognit - gocyclo - godot - godox + - godox - goerr113 - - errorlint - gofumpt - goimports - golint @@ -51,15 +61,19 @@ linters: - gosec - gosimple - govet - - ineffassign - ifshort + - ifshort + - importas + - ineffassign + - ireturn - lll + - maintidx - megacheck - misspell - nakedret + - nestif - noctx - nolintlint - - nestif - rowserrcheck - scopelint - staticcheck diff --git a/README.md b/README.md index 377865e..7dd30c1 100755 --- a/README.md +++ b/README.md @@ -6,26 +6,31 @@ Tries to give an answer if in an example code one can find: "error handling omit The service represents a very simple URL shortener service. Offers basic CRD (create, read, delete) operations via REST. -Based on the series from [tensor-programming](https://github.com/tensor-programming/hex-microservice.git) and recommendations from [Kat Zień](https://github.com/katzien/go-structure-examples). +Based on the series from [tensor-programming](https://github.com/tensor-programming/hex-microservice.git) and recommendations from: + +- [How do you structure your Go apps? - Kat Zień](https://github.com/katzien/go-structure-examples) +- [Improving the code from the official Go RESTful API tutorial - Ben Hoyt](https://benhoyt.com/writings/web-service-stdlib/) # Disclaimer The implementation in this repository ~~could be a little~~ _is_ over-engineered for such a simple project. It exists mostly for its educational purpose and as an experiment to break with traditional approaches (e.g. the active record pattern, ORM, storing JSON in redis, coupling of the domain and the entities). -# Golang 1.18 - -As of today (January, 2022), golang 1.18 with generics is not yet released. `gotip` installs the latest go build (which includes 1.18): +## Golang 1.18 -[Source](https://gist.github.com/nikgalushko/e1b5c85c64653dd554a7a904bbef4eee): +As of today (January, 2022), golang 1.18 with generics is not yet released, but betas are available. Please refer to: https://go.dev/blog/go1.18beta2 -## Install gotip +### Install gotip ``` -go install golang.org/dl/gotip@latest -gotip download +go install golang.org/dl/go1.18beta2@latest +go1.18beta2 download ``` -## Install latest gopls +### Install latest gopls + +1. Either in Codium / VS Studio Code: "Go: Install/Update Tools" + +2. Or manually. Example for POSIX based systems: ``` mkdir /tmp/gopls && cd "$_" @@ -34,11 +39,11 @@ gotip get golang.org/x/tools/gopls@master golang.org/x/tools@master gotip install golang.org/x/tools/gopls ``` -## Configure VSCode +### Configure VSCode/Codium 1. View > Command Palette 2. Go: choose Go environment -3. select gotip +3. select go1.18beta2 # Structure of the project @@ -98,7 +103,7 @@ Another architectural style was defined by Jeffrey Palermo a few years later in # Todo and Ideas - implement and test mongo backend -- implement and test other routers than `chi` +- ~~implement and test other routers than `chi`~~ - implement the code generator that creates the conversion code that performs the conversion without runtime inspection (reflection) - compare this custom golang lib version (this) with an existing framework like spring boot (e.g. input validation) - handle key collisions diff --git a/Taskfile.yml b/Taskfile.yml index 43b809b..4fd5f69 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,5 +1,7 @@ version: "3" +dotenv: ["taskfile.env"] + vars: FOLDER_DIST: "dist" @@ -81,7 +83,7 @@ tasks: test: desc: Perform all tests cmds: - - go test -cover -race ./... + - ${GO} test -cover -race ./... build: desc: Build the binary for the current architecture. @@ -122,14 +124,13 @@ tasks: cmds: - mkdir -p "{{.FOLDER}}" - >- - go build -trimpath + CGO_ENABLED=1 ${GO} build -trimpath -ldflags="-w -s -X main.name="{{.NAME}}{{.EXTENSION}}" -X main.version="{{.GIT_COMMIT}}" -X main.realm="{{.REALM}}" -extldflags '-static'" -a -buildvcs=false - -buildinfo=false -o {{.OUTPUT}} {{.MAIN}} diff --git a/cmd/service/chi.go b/cmd/service/chi.go deleted file mode 100755 index 05b6703..0000000 --- a/cmd/service/chi.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "fmt" - "hex-microservice/adder" - "hex-microservice/deleter" - httpservice "hex-microservice/http" - "hex-microservice/lookup" - "net/http" - - chi "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-logr/logr" -) - -// newChiRouter returns a http.Handler that exposes the service with the chi router. -func newChiRouter(log logr.Logger, mappedURL string, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.StripSlashes) - - s := httpservice.New(log, a, l, d, chi.URLParam) - - r.Get(fmt.Sprintf("/{%s}", httpservice.UrlParameterCode), s.RedirectGet(mappedURL)) - r.Post("/", s.RedirectPost(mappedURL)) - r.Delete(fmt.Sprintf("/{%s}/{%s}", httpservice.UrlParameterCode, httpservice.UrlParameterToken), s.RedirectDelete(mappedURL)) - - return r -} diff --git a/cmd/service/chirouter/chi.go b/cmd/service/chirouter/chi.go new file mode 100755 index 0000000..d8742bf --- /dev/null +++ b/cmd/service/chirouter/chi.go @@ -0,0 +1,38 @@ +package chirouter + +import ( + "fmt" + "hex-microservice/adder" + "hex-microservice/deleter" + "hex-microservice/health" + "hex-microservice/http/rest" + "hex-microservice/lookup" + "net/http" + + chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-logr/logr" +) + +// New returns a http.Handler that exposes the service with the chi router. +func New(log logr.Logger, mappedURL string, h health.Service, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.StripSlashes) + + r.NotFound(http.NotFound) + r.MethodNotAllowed(http.NotFound) + + s := rest.New(log, h, a, l, d, chi.URLParam) + + r.Get("/health", s.Health()) + + r.Get(fmt.Sprintf("/{%s}", rest.UrlParameterCode), s.RedirectGet(mappedURL)) + r.Post("/", s.RedirectPost(mappedURL)) + r.Delete(fmt.Sprintf("/{%s}/{%s}", rest.UrlParameterCode, rest.UrlParameterToken), s.RedirectDelete(mappedURL)) + + return r +} diff --git a/cmd/service/gorillamux.go b/cmd/service/gorillamux.go deleted file mode 100755 index 6ed0a5b..0000000 --- a/cmd/service/gorillamux.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "hex-microservice/adder" - "hex-microservice/deleter" - "hex-microservice/lookup" - "net/http" - - "github.com/go-logr/logr" - "github.com/gorilla/mux" -) - -func newGorillaMuxRouter(log logr.Logger, mappedURL string, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { - router := mux.NewRouter().StrictSlash(true) - - /* - r.HandleFunc("/products", ProductsHandler). - - Methods("GET"). - */ - - return router -} diff --git a/cmd/service/gorouter/gorouter.go b/cmd/service/gorouter/gorouter.go new file mode 100644 index 0000000..5462366 --- /dev/null +++ b/cmd/service/gorouter/gorouter.go @@ -0,0 +1,151 @@ +package gorouter + +import ( + "context" + "encoding/json" + "hex-microservice/adder" + "hex-microservice/deleter" + "hex-microservice/health" + "hex-microservice/http/rest" + "hex-microservice/lookup" + "net/http" + "strings" + + "github.com/go-logr/logr" +) + +// +// See: https://benhoyt.com/writings/web-service-stdlib/ +// + +type router struct{} + +type goRouter func(http.ResponseWriter, *http.Request) + +func (f goRouter) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + f(rw, r) +} + +const ( + ErrorAlreadyExists = "already-exists" + ErrorDatabase = "database" + ErrorInternal = "internal" + ErrorMalformedJSON = "malformed-json" + ErrorMethodNotAllowed = "method-not-allowed" + ErrorNotFound = "not-found" + ErrorValidation = "validation" +) + +const varsKey = "UrlParameter" + +func match(r *http.Request, path string, vars ...string) *http.Request { + matches := strings.Split(path, "/") + lenMatches := len(matches) + lenVars := len(vars) + + if lenMatches == 1 && matches[0] == "" || lenMatches != lenVars { + return nil + } + + parts := make(map[string]string, lenMatches) + + for i, m := range matches { + parts[vars[i]] = m + } + + ctx := context.WithValue(r.Context(), varsKey, parts) + + return r.WithContext(ctx) +} + +func paramFunc(r *http.Request, key string) string { + if rv := r.Context().Value(varsKey); rv != nil { + if kv, ok := rv.(map[string]string); ok { + return kv[key] + } + } + + return "" +} + +func withoutTrailing(path string) string { + if path != "/" { + path = strings.TrimRight(path, "/") + } + + return path +} + +func withoutPrefix(path, prefix string) string { + return strings.TrimLeft(path, prefix) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(status) + w.Write(b) +} + +func jsonError(w http.ResponseWriter, status int, error string, data map[string]interface{}) { + response := struct { + Status int `json:"status"` + Error string `json:"error"` + Data map[string]interface{} `json:"data,omitempty"` + }{ + Status: status, + Error: error, + Data: data, + } + + writeJSON(w, status, response) +} + +// newGoRouter +func New(log logr.Logger, mappedURL string, h health.Service, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { + s := rest.New(log, h, a, l, d, paramFunc) + + return goRouter(func(rw http.ResponseWriter, r *http.Request) { + path := withoutTrailing(r.URL.Path) + + log.Info("router", "method", r.Method, "path", path) + + switch path { + case "/health": + switch r.Method { + case "GET": + s.Health()(rw, r) + return + } + case "/": + switch r.Method { + case "POST": + s.RedirectPost(mappedURL)(rw, r) + return + } + } + + const prefix = "/" + if r := match(r, withoutPrefix(path, prefix), rest.UrlParameterCode); r != nil { + switch r.Method { + case "GET": + s.RedirectGet(mappedURL)(rw, r) + return + } + } + + if r := match(r, withoutPrefix(path, prefix), rest.UrlParameterCode, rest.UrlParameterToken); r != nil { + switch r.Method { + case "DELETE": + s.RedirectDelete(mappedURL)(rw, r) + return + } + } + + jsonError(rw, http.StatusNotFound, ErrorNotFound, nil) + }) +} diff --git a/cmd/service/gorouter/gorouter_test.go b/cmd/service/gorouter/gorouter_test.go new file mode 100644 index 0000000..1355f0b --- /dev/null +++ b/cmd/service/gorouter/gorouter_test.go @@ -0,0 +1,91 @@ +package gorouter + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const defaultValueNotDefined = "" + +func TestMatchRoutes(t *testing.T) { + for _, f := range []struct { + name string + path string + prefix string + vars []string + expected map[string]string + }{ + { + name: "single value", + path: "/foo/", + prefix: "/", + vars: []string{"id"}, + expected: map[string]string{"id": "foo"}, + }, + { + name: "two values", + path: "/foo/bar", + prefix: "/", + vars: []string{"id", "id2"}, + expected: map[string]string{"id": "foo", "id2": "bar"}, + }, + } { + f := f // pin + t.Run(f.name, func(t *testing.T) { + t.Parallel() + + path := withoutPrefix(withoutTrailing(f.path), f.prefix) + + r := match(&http.Request{}, path, f.vars...) + if assert.NotNil(t, r) { + for k, v := range f.expected { + assert.Equal(t, v, paramFunc(r, k)) + } + } + }) + } +} + +func TestNoMatchRoutes(t *testing.T) { + for _, f := range []struct { + name string + path string + prefix string + vars []string + }{ + { + name: "no vars", + path: "/foo/", + prefix: "/", + vars: []string{}, + }, + { + name: "more values than in the path", + path: "/foo/", + prefix: "/", + vars: []string{"id", "id2"}, + }, + { + name: "more in the path than values", + path: "/foo/bar/", + prefix: "/", + vars: []string{"id"}, + }, + } { + f := f // pin + t.Run(f.name, func(t *testing.T) { + t.Parallel() + + path := withoutPrefix(withoutTrailing(f.path), f.prefix) + + r := match(&http.Request{}, path, f.vars...) + assert.Nil(t, r) + }) + } +} + +func TestNoMatch(t *testing.T) { + assert.Equal(t, defaultValueNotDefined, paramFunc(&http.Request{}, "foo")) +} diff --git a/cmd/service/httprouter.go b/cmd/service/httprouter.go deleted file mode 100755 index 37971aa..0000000 --- a/cmd/service/httprouter.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "fmt" - "hex-microservice/adder" - "hex-microservice/deleter" - httpservice "hex-microservice/http" - "hex-microservice/lookup" - "net/http" - - "github.com/go-logr/logr" - "github.com/julienschmidt/httprouter" -) - -// adapt takes a regular http.HandlerFunc and adapts it to use with httprouter.Handle. -func adapt(h http.HandlerFunc) httprouter.Handle { - return func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - h(rw, r) - } -} - -// newHttpRouter returns a http.Handler that adapts the service with the use of the httprouter router. -func newHttpRouter(log logr.Logger, mappedURL string, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { - r := httprouter.New() - - s := httpservice.New(log, a, l, d, func(r *http.Request, key string) string { - return httprouter.ParamsFromContext(r.Context()).ByName(key) - }) - - r.GET(fmt.Sprintf("/:%s", httpservice.UrlParameterCode), adapt(s.RedirectGet(mappedURL))) - r.POST("/", adapt(s.RedirectPost(mappedURL))) - r.GET(fmt.Sprintf("/:%s/:%s", httpservice.UrlParameterCode, httpservice.UrlParameterToken), adapt(s.RedirectDelete(mappedURL))) - - return r -} diff --git a/cmd/service/httprouter/httprouter.go b/cmd/service/httprouter/httprouter.go new file mode 100755 index 0000000..13feac7 --- /dev/null +++ b/cmd/service/httprouter/httprouter.go @@ -0,0 +1,42 @@ +package httprouter + +import ( + "fmt" + "hex-microservice/adder" + "hex-microservice/deleter" + "hex-microservice/health" + "hex-microservice/http/rest" + "hex-microservice/lookup" + "net/http" + "strings" + + "github.com/go-logr/logr" + org "github.com/julienschmidt/httprouter" +) + +func paramFunc(r *http.Request, key string) string { + return org.ParamsFromContext(r.Context()).ByName(key) +} + +// newHttpRouter returns a http.Handler that adapts the service with the use of the httprouter router. +func New(log logr.Logger, mappedURL string, h health.Service, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { + r := org.New() + r.HandleMethodNotAllowed = false + + s := rest.New(log, h, a, l, d, paramFunc) + + // this is bad: https://github.com/julienschmidt/httprouter/issues/183 + r.HandlerFunc("GET", fmt.Sprintf("/:%s", rest.UrlParameterCode), func(rw http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/health") { + s.Health()(rw, r) + return + } + + s.RedirectGet(mappedURL)(rw, r) + }) + + r.Handler("DELETE", fmt.Sprintf("/:%s/:%s", rest.UrlParameterCode, rest.UrlParameterToken), s.RedirectDelete(mappedURL)) + r.Handler("POST", "/", s.RedirectPost(mappedURL)) + + return r +} diff --git a/cmd/service/main.go b/cmd/service/main.go index db1ce6f..43415fb 100755 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -10,8 +10,13 @@ import ( "errors" "fmt" "hex-microservice/adder" + "hex-microservice/cmd/service/chirouter" + "hex-microservice/cmd/service/gorouter" + "hex-microservice/cmd/service/httprouter" + "hex-microservice/cmd/service/muxrouter" "hex-microservice/customcontext" "hex-microservice/deleter" + "hex-microservice/health" "hex-microservice/lookup" "hex-microservice/meta/value" "hex-microservice/repository" @@ -63,9 +68,9 @@ const repositoryTypeSeparator = ":" var ( // used default repository implementation - defaultRepository = repoMemory + defaultRepository = repositoryImplementations[0] // used default router implementation - defaultRouter = routerChi + defaultRouter = routerImplementations[0] ) // repositoryImpl represents a repository implementation that can be instantiated. @@ -80,33 +85,27 @@ func (r routerImpl) String() string { return r.name } // repositoryImpl represents a router implementation that can be instantiated. type routerImpl struct { name string - new func(logr.Logger, string, adder.Service, lookup.Service, deleter.Service) http.Handler + new func(logr.Logger, string, health.Service, adder.Service, lookup.Service, deleter.Service) http.Handler } // String returns the string representation of the repositoryImpl. func (r repositoryImpl) String() string { return r.name } // available router implementations -// uses symbolic names (e.g. like enums) rather than strings. -var ( - routerChi = routerImpl{"chi", newChiRouter} - routerGorillaMux = routerImpl{"gorilla", newGorillaMuxRouter} - routerHttpRouter = routerImpl{"httprouter", newHttpRouter} - - // valid implementations - routerImplementations = []routerImpl{routerChi, routerGorillaMux, routerHttpRouter} -) +var routerImplementations = []routerImpl{ + {"go", gorouter.New}, + {"chi", chirouter.New}, + {"gorilla", muxrouter.New}, + {"httprouter", httprouter.New}, +} // available repository implementations -// uses symbolic names (e.g. like enums) rather than strings. -var ( - repoMemory = repositoryImpl{"memory", memory.New} - repoRedis = repositoryImpl{"redis", redis.New} - repoMongo = repositoryImpl{"mongodb", mongo.New} - - // valid implementations - repositoryImplementations = []repositoryImpl{repoMemory, repoRedis, repoMongo} -) +var repositoryImplementations = []repositoryImpl{ + {"memory", memory.New}, + {"redis", redis.New}, + {"mongodb", mongo.New}, + //"sqlite", sqlite.New}, +} // configuration describes the user defined configuration options. type configuration struct { @@ -190,10 +189,11 @@ func run(parent context.Context, log logr.Logger) error { lookupService := lookup.New(log, repository) adderService := adder.New(log, repository) deleteService := deleter.New(log, repository) + healthService := health.New(name, version) // initialize the configured router // use a factory function (new) of the supported type - router := c.Router.new(log, c.Mapped, adderService, lookupService, deleteService) + router := c.Router.new(log, c.Mapped, healthService, adderService, lookupService, deleteService) // use the built-in http server server := &http.Server{ diff --git a/cmd/service/main_test.go b/cmd/service/main_test.go new file mode 100644 index 0000000..17f4269 --- /dev/null +++ b/cmd/service/main_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "hex-microservice/adder" + "hex-microservice/deleter" + "hex-microservice/health" + "hex-microservice/lookup" + "hex-microservice/repository/memory" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-logr/stdr" + "github.com/stretchr/testify/assert" +) + +var discardingLogger = stdr.New(log.New(io.Discard, "", log.Lshortfile)) + +const ( + contentTypeMessagePack = "application/x-msgpack" + contentTypeJson = "application/json" +) + +func TestHealth(t *testing.T) { + t.Parallel() + + const ( + name = "name" + version = "version" + ) + + healthService := health.New(name, version) + + type healthResponse struct { + Name string `json:"name"` + Version string `json:"version"` + } + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + router := ri.new(discardingLogger, "", healthService, nil, nil, nil) + request := httptest.NewRequest("GET", "/health", nil) + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + if assert.Equal(t, http.StatusOK, responseRecorder.Result().StatusCode) { + if assert.Equal(t, "application/json", responseRecorder.Header().Get("content-type")) { + response := &healthResponse{} + json.Unmarshal(responseRecorder.Body.Bytes(), response) + assert.Equal(t, &healthResponse{Name: name, Version: version}, response) + } + } + }) + } +} + +func TestRedirectGetRoot(t *testing.T) { + t.Parallel() + + repository, err := memory.New(context.Background(), "") + if assert.NoError(t, err) { + lookupService := lookup.New(discardingLogger, repository) + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + router := ri.new(discardingLogger, "", nil, nil, lookupService, nil) + request := httptest.NewRequest("GET", "/", nil) + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + assert.Equal(t, http.StatusNotFound, responseRecorder.Result().StatusCode) + }) + } + + } +} + +func TestRedirectGetExisting(t *testing.T) { + t.Parallel() + + const ( + code = "code" + token = "token" + url = "https://example.com/" + ) + + repository, err := memory.New(context.Background(), "") + if assert.NoError(t, err) { + err = repository.Store(adder.RedirectStorage{ + Code: code, + Token: token, + URL: url, + }) + + if assert.NoError(t, err) { + lookupService := lookup.New(discardingLogger, repository) + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + router := ri.new(discardingLogger, "", nil, nil, lookupService, nil) + request := httptest.NewRequest("GET", "/"+code, nil) + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + if assert.Equal(t, http.StatusTemporaryRedirect, responseRecorder.Result().StatusCode) { + assert.Equal(t, url, responseRecorder.Result().Header.Get("location")) + } + }) + } + } + } +} + +func TestRedirectAdd(t *testing.T) { + t.Parallel() + + const url = "https://example.com/" + + payload := fmt.Sprintf(`{ "url": "%s" }`, url) + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + repository, err := memory.New(context.Background(), "") + if assert.NoError(t, err) { + adderService := adder.New(discardingLogger, repository) + + router := ri.new(discardingLogger, "", nil, adderService, nil, nil) + + request := httptest.NewRequest("POST", "/", strings.NewReader(payload)) + request.Header.Set("Content-Type", contentTypeJson) + + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + assert.Equal(t, http.StatusCreated, responseRecorder.Result().StatusCode) + } + }) + } +} + +func TestRedirectDeleteNonExisting(t *testing.T) { + t.Parallel() + + const ( + code = "code" + token = "token" + ) + + repository, err := memory.New(context.Background(), "") + if assert.NoError(t, err) { + deleterService := deleter.New(discardingLogger, repository) + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + router := ri.new(discardingLogger, "", nil, nil, nil, deleterService) + + request := httptest.NewRequest("DELETE", "/"+code+"/"+token, nil) + + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + assert.Equal(t, http.StatusNotFound, responseRecorder.Result().StatusCode) + }) + } + } +} + +func TestRedirectDeleteExisting(t *testing.T) { + t.Parallel() + + const ( + code = "code" + token = "token" + ) + + for _, ri := range routerImplementations { + ri := ri // pin + + t.Run(ri.name, func(t *testing.T) { + t.Parallel() + + repository, err := memory.New(context.Background(), "") + if assert.NoError(t, err) { + err = repository.Store(adder.RedirectStorage{ + Code: code, + Token: token, + }) + + if assert.NoError(t, err) { + deleterService := deleter.New(discardingLogger, repository) + + router := ri.new(discardingLogger, "", nil, nil, nil, deleterService) + + request := httptest.NewRequest("DELETE", "/"+code+"/"+token, nil) + + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + + assert.Equal(t, http.StatusNoContent, responseRecorder.Result().StatusCode) + } + } + }) + } +} diff --git a/cmd/service/muxrouter/gorillamux.go b/cmd/service/muxrouter/gorillamux.go new file mode 100755 index 0000000..6e02bde --- /dev/null +++ b/cmd/service/muxrouter/gorillamux.go @@ -0,0 +1,40 @@ +package muxrouter + +import ( + "fmt" + "hex-microservice/adder" + "hex-microservice/deleter" + "hex-microservice/health" + "hex-microservice/http/rest" + "hex-microservice/lookup" + "net/http" + + "github.com/go-chi/chi/v5/middleware" + "github.com/go-logr/logr" + "github.com/gorilla/mux" +) + +func New(log logr.Logger, mappedURL string, h health.Service, a adder.Service, l lookup.Service, d deleter.Service) http.Handler { + r := mux.NewRouter() + r.StrictSlash(true) + r.NotFoundHandler = http.NotFoundHandler() + r.MethodNotAllowedHandler = http.NotFoundHandler() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.StripSlashes) + + s := rest.New(log, h, a, l, d, func(r *http.Request, key string) string { + return mux.Vars(r)[key] + }) + + r.HandleFunc("/health", s.Health()).Methods("GET") + + r.HandleFunc(fmt.Sprintf("/{%s}", rest.UrlParameterCode), s.RedirectGet(mappedURL)).Methods("GET") + r.HandleFunc("/", s.RedirectPost(mappedURL)).Methods("POST") + r.HandleFunc(fmt.Sprintf("/{%s}/{%s}", rest.UrlParameterCode, rest.UrlParameterToken), s.RedirectDelete(mappedURL)).Methods("DELETE") + + return r +} diff --git a/health/service.go b/health/service.go new file mode 100644 index 0000000..f22802a --- /dev/null +++ b/health/service.go @@ -0,0 +1,30 @@ +package health + +type service struct { + name string + version string +} + +type HealthResult struct { + Name string + Version string + Repository bool +} + +type Service interface { + Health() HealthResult +} + +func New(name, version string) Service { + return &service{ + name: name, + version: version, + } +} + +func (s *service) Health() HealthResult { + return HealthResult{ + Name: s.name, + Version: s.version, + } +} diff --git a/http/delete.go b/http/rest/delete.go similarity index 97% rename from http/delete.go rename to http/rest/delete.go index aaad9b8..32c2cb9 100644 --- a/http/delete.go +++ b/http/rest/delete.go @@ -1,4 +1,4 @@ -package service +package rest import ( "errors" diff --git a/http/get.go b/http/rest/get.go similarity index 88% rename from http/get.go rename to http/rest/get.go index d67ba42..68a487f 100644 --- a/http/get.go +++ b/http/rest/get.go @@ -1,4 +1,4 @@ -package service +package rest import ( "errors" @@ -23,7 +23,7 @@ func (h *handler) RedirectGet(mappingUrl string) http.HandlerFunc { return } - http.Redirect(w, r, redirect.URL, http.StatusMovedPermanently) + http.Redirect(w, r, redirect.URL, http.StatusTemporaryRedirect) return } } diff --git a/http/rest/health.go b/http/rest/health.go new file mode 100644 index 0000000..860b008 --- /dev/null +++ b/http/rest/health.go @@ -0,0 +1,28 @@ +package rest + +import ( + "encoding/json" + "net/http" +) + +type healthResponse struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func (h *handler) Health() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + health := h.health.Health() + + response, err := json.Marshal(healthResponse{ + Name: health.Name, + Version: health.Version, + }) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + writeResponse(w, contentTypeJson, response, http.StatusOK) + } +} diff --git a/http/post.go b/http/rest/post.go similarity index 99% rename from http/post.go rename to http/rest/post.go index 7c1a17c..8580446 100644 --- a/http/post.go +++ b/http/rest/post.go @@ -1,4 +1,4 @@ -package service +package rest import ( "errors" diff --git a/http/rest.go b/http/rest/rest.go similarity index 92% rename from http/rest.go rename to http/rest/rest.go index 9fb4dd6..df57be4 100755 --- a/http/rest.go +++ b/http/rest/rest.go @@ -1,9 +1,10 @@ -package service +package rest import ( "encoding/json" "hex-microservice/adder" "hex-microservice/deleter" + "hex-microservice/health" "hex-microservice/lookup" "net/http" @@ -28,6 +29,7 @@ const ( type ParamFn func(r *http.Request, key string) string type Handler interface { + Health() http.HandlerFunc RedirectGet(mappingUrl string) http.HandlerFunc RedirectPost(mappingUrl string) http.HandlerFunc RedirectDelete(mappingUrl string) http.HandlerFunc @@ -69,6 +71,7 @@ type handler struct { adder adder.Service lookup lookup.Service deleter deleter.Service + health health.Service converters map[string]converter } @@ -77,10 +80,11 @@ type ApiError struct { Title string `json:"title"` } -func New(log logr.Logger, adder adder.Service, lookup lookup.Service, deleter deleter.Service, paramFn ParamFn) Handler { +func New(log logr.Logger, health health.Service, adder adder.Service, lookup lookup.Service, deleter deleter.Service, paramFn ParamFn) Handler { return &handler{ log: log, paramFn: paramFn, + health: health, adder: adder, lookup: lookup, deleter: deleter, diff --git a/taskfile.env b/taskfile.env new file mode 100644 index 0000000..0860582 --- /dev/null +++ b/taskfile.env @@ -0,0 +1 @@ +GO=go1.18beta2