├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── workflow.yaml ├── .gitignore ├── .mockery.yaml ├── .pre-commit-config.yaml ├── .redocly.lint-ignore.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── casbin ├── model.conf └── policy.csv ├── cmd ├── server │ └── main.go └── superuser │ └── main.go ├── codecov.yml ├── config └── config.go ├── configs ├── config.local.toml └── config.prod.toml ├── data ├── db.go └── mapper.go ├── docs └── index.html ├── go.mod ├── go.sum ├── handlers ├── auth.go ├── auth_test.go ├── handler.go ├── mock_PersonalAccessTokenService.go ├── mock_TaskService.go ├── mock_UserService.go ├── oauth2_google.go ├── personal_access_token.go ├── personal_access_token_test.go ├── root.go ├── root_test.go ├── setup_test.go ├── task.go ├── task_test.go ├── user.go └── user_test.go ├── mappers ├── personal_access_token.go ├── task.go └── user.go ├── models ├── error.go ├── model.go ├── model_test.go ├── personal_access_token.go ├── personal_access_token_test.go ├── setup_test.go ├── task.go ├── task_test.go ├── user.go └── user_test.go ├── openapi ├── components │ ├── headers │ │ ├── Link.yaml │ │ ├── SetCookie.yaml │ │ ├── SetCookieRefresh.yaml │ │ ├── X-Next-Page.yaml │ │ ├── X-Page.yaml │ │ ├── X-Per-Page.yaml │ │ ├── X-Prev-Page.yaml │ │ ├── X-Total-Pages.yaml │ │ └── X-Total.yaml │ ├── responses │ │ ├── BadRequest.yaml │ │ ├── Conflict.yaml │ │ ├── Forbidden.yaml │ │ ├── Gone.yaml │ │ ├── NotFound.yaml │ │ ├── Unauthorized.yaml │ │ ├── Unexpected.yaml │ │ └── UnprocessableEntity.yaml │ ├── schemas │ │ ├── Error.yaml │ │ ├── ValidationError.yaml │ │ ├── auth │ │ │ ├── Login.yaml │ │ │ ├── Logout.yaml │ │ │ ├── RefreshToken.yaml │ │ │ ├── Signup.yaml │ │ │ ├── Token.yaml │ │ │ └── TokenResponse.yaml │ │ ├── personal_access_tokens │ │ │ ├── Create.yaml │ │ │ ├── Create_response.yaml │ │ │ ├── List.yaml │ │ │ └── Token.yaml │ │ ├── tasks │ │ │ ├── Create.yaml │ │ │ ├── List.yaml │ │ │ ├── Task.yaml │ │ │ ├── Transition.yaml │ │ │ └── Update.yaml │ │ └── users │ │ │ ├── List.yaml │ │ │ ├── Ref.yaml │ │ │ ├── Update.yaml │ │ │ ├── User.yaml │ │ │ └── me │ │ │ ├── CurrentUser.yaml │ │ │ └── Update.yaml │ └── securitySchemes │ │ ├── BearerAuth.yaml │ │ └── CookieAuth.yaml ├── openapi.yaml └── paths │ ├── auth │ ├── login.yaml │ ├── logout.yaml │ ├── refresh.yaml │ ├── signup.yaml │ └── token.yaml │ ├── personal_access_tokens │ ├── personal_access_tokens.yaml │ └── personal_access_tokens_{id}.yaml │ ├── tasks │ ├── tasks.yaml │ ├── {id}.yaml │ └── {id}_transition.yaml │ └── users │ ├── me.yaml │ ├── users.yaml │ ├── {username}.yaml │ ├── {username}_ban.yaml │ ├── {username}_lock.yaml │ └── {username}_roles_{role}.yaml ├── server ├── server.go └── server_test.go ├── services ├── error.go ├── error_test.go ├── mock_PersonalAccessTokenMapper.go ├── mock_TaskMapper.go ├── mock_UserMapper.go ├── personal_access_token.go ├── personal_access_token_test.go ├── setup_test.go ├── task.go ├── task_test.go ├── user.go └── user_test.go ├── testing └── testing.go └── util ├── bson └── bson.go ├── cookie ├── cookie.go ├── cookie_test.go └── setup_test.go ├── hash ├── hash.go └── hash_test.go ├── jwt ├── jwt.go ├── jwt_test.go └── setup_test.go ├── pagination ├── pagination.go └── pagination_test.go ├── password ├── password.go └── password_test.go └── rand ├── rand.go └── rand_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # GoLand 15 | .idea 16 | 17 | # binary 18 | server-bin 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [{Makefile,go.mod,go.sum,*.go}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | [*.md] 16 | indent_size = 4 17 | trim_trailing_whitespace = false 18 | 19 | [Dockerfile] 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Test and coverage 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 2 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.22' 19 | - name: Make private key 20 | run: openssl genrsa -out private-key.pem 4096 21 | - name: Run coverage 22 | run: go test -race -coverprofile=coverage.out -covermode=atomic ./... 23 | - name: Upload coverage to Codecov 24 | uses: codecov/codecov-action@v4 25 | with: 26 | token: ${{ secrets.CODECOV_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # GoLand 15 | .idea 16 | 17 | # binary 18 | server-bin 19 | 20 | private-key.pem 21 | cert.pem 22 | key.pem 23 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | with-expecter: True 2 | filename: "mock_{{.InterfaceName}}.go" 3 | dir: "{{.InterfaceDir}}" 4 | mockname: "Mock{{.InterfaceName}}" 5 | outpkg: "{{.PackageName}}" 6 | inpackage: True 7 | packages: 8 | github.com/alexferl/echo-boilerplate/handlers: 9 | interfaces: 10 | PersonalAccessTokenService: 11 | TaskService: 12 | UserService: 13 | github.com/alexferl/echo-boilerplate/services: 14 | interfaces: 15 | PersonalAccessTokenMapper: 16 | TaskMapper: 17 | UserMapper: 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-json 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: mixed-line-ending 11 | args: ['--fix=lf'] 12 | description: Forces to replace line ending by the UNIX 'lf' character. 13 | - id: pretty-format-json 14 | args: ['--autofix', '--no-sort-keys', '--no-ensure-ascii'] 15 | -------------------------------------------------------------------------------- /.redocly.lint-ignore.yaml: -------------------------------------------------------------------------------- 1 | # This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. 2 | # See https://redoc.ly/docs/cli/ for more information. 3 | openapi/openapi.yaml: 4 | no-server-example.com: 5 | - '#/servers/0/url' 6 | - '#/servers/1/url' 7 | - '#/servers/2/url' 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=1.22-alpine 2 | FROM golang:${GOLANG_VERSION} AS builder 3 | MAINTAINER Alexandre Ferland 4 | 5 | WORKDIR /build 6 | 7 | RUN apk add --no-cache git 8 | 9 | COPY go.mod . 10 | COPY go.sum . 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | RUN CGO_ENABLED=0 go build -ldflags="-w -s" ./cmd/server 16 | 17 | FROM scratch 18 | COPY --from=builder /build/server /server 19 | COPY --from=builder /build/configs /configs 20 | 21 | ENTRYPOINT ["/server"] 22 | 23 | EXPOSE 1323 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexandre Ferland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dev run test cover fmt mock openapi-lint pre-commit docker-build docker-run 2 | 3 | .DEFAULT: help 4 | help: 5 | @echo "make dev" 6 | @echo " setup development environment" 7 | @echo "make run" 8 | @echo " run app" 9 | @echo "make test" 10 | @echo " run go test" 11 | @echo "make cover" 12 | @echo " run go test with -cover" 13 | @echo "make cover-html" 14 | @echo " run go test with -cover and show HTML" 15 | @echo "make tidy" 16 | @echo " run go mod tidy" 17 | @echo "make fmt" 18 | @echo " run gofumpt" 19 | @echo "make mock" 20 | @echo " run mockery" 21 | @echo "make openapi-lint" 22 | @echo " lint openapi spec" 23 | @echo "make pre-commit" 24 | @echo " run pre-commit hooks" 25 | @echo "make docker-build" 26 | @echo " build docker image" 27 | @echo "make docker-run" 28 | @echo " run docker image" 29 | 30 | check-gofumpt: 31 | ifeq (, $(shell which gofumpt)) 32 | $(error "gofumpt not in $(PATH), gofumpt (https://pkg.go.dev/mvdan.cc/gofumpt) is required") 33 | endif 34 | 35 | check-pre-commit: 36 | ifeq (, $(shell which pre-commit)) 37 | $(error "pre-commit not in $(PATH), pre-commit (https://pre-commit.com) is required") 38 | endif 39 | 40 | check-redocly: 41 | ifeq (, $(shell which redocly)) 42 | $(error "redocly not in $(PATH), redocly (https://redocly.com/docs/cli/installation/) is required") 43 | endif 44 | 45 | dev: check-pre-commit 46 | ifeq (,$(wildcard ./private-key.pem)) 47 | @echo "No private key file, generating one..." 48 | openssl genrsa -out private-key.pem 4096 49 | endif 50 | pre-commit install 51 | 52 | run: 53 | go build -o server-bin ./cmd/server && ./server-bin 54 | 55 | build: 56 | go build -o server-bin ./cmd/server 57 | 58 | test: 59 | go test -v ./... 60 | 61 | cover: 62 | go test -cover -v ./... 63 | 64 | cover-html: 65 | go test -v -coverprofile=coverage.out ./... 66 | go tool cover -html=coverage.out 67 | 68 | tidy: 69 | go mod tidy 70 | 71 | fmt: check-gofumpt 72 | gofumpt -l -w . 73 | 74 | mock: 75 | mockery 76 | 77 | openapi-lint: check-redocly 78 | redocly lint openapi/openapi.yaml 79 | 80 | pre-commit: check-pre-commit 81 | pre-commit 82 | 83 | docker-build: 84 | docker build -t echo-boilerplate . 85 | 86 | docker-run: 87 | docker run -p 1323:1323 --rm echo-boilerplate --http-bind-address 0.0.0.0 88 | -------------------------------------------------------------------------------- /casbin/model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = g(r.sub, p.sub) && keyMatch4(r.obj, p.obj) && regexMatch(r.act, p.act) 15 | -------------------------------------------------------------------------------- /casbin/policy.csv: -------------------------------------------------------------------------------- 1 | p, any, /, GET 2 | p, any, /readyz, GET 3 | p, any, /livez, GET 4 | p, any, /docs, GET 5 | p, any, /openapi/*, GET 6 | 7 | p, any, /auth/login, POST 8 | p, any, /auth/logout, POST 9 | p, any, /auth/refresh, POST 10 | p, any, /auth/signup, POST 11 | p, any, /auth/token, GET 12 | p, any, /google, GET 13 | p, any, /oauth2/*/login, GET 14 | p, any, /oauth2/*/callback, GET 15 | 16 | p, user, /me, (GET)|(PATCH) 17 | p, user, /me/personal_access_tokens, (GET)|(POST) 18 | p, user, /me/personal_access_tokens/:id, (GET)|(DELETE) 19 | p, user, /tasks, (GET)|(POST) 20 | p, user, /tasks/:id, (GET)|(PATCH)|(DELETE) 21 | p, user, /tasks/:id/transition, PUT 22 | p, user, /users/:username, GET 23 | 24 | p, admin, /users, GET 25 | p, admin, /users/:username, PATCH 26 | p, admin, /users/:username/ban, (PUT)|(DELETE) 27 | p, admin, /users/:username/lock, (PUT)|(DELETE) 28 | p, admin, /users/:username/roles/:role, (PUT)|(DELETE) 29 | 30 | g, *, any 31 | g, user, any 32 | g, admin, user 33 | g, super, admin 34 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/alexferl/echo-boilerplate/config" 11 | "github.com/alexferl/echo-boilerplate/server" 12 | ) 13 | 14 | func main() { 15 | c := config.New() 16 | c.BindFlags() 17 | 18 | s := server.New() 19 | 20 | log.Info().Msgf( 21 | "Starting %s on %s environment listening at http://%s", 22 | viper.GetString(config.AppName), 23 | strings.ToUpper(viper.GetString(config.EnvName)), 24 | fmt.Sprintf("%s:%d", viper.GetString(config.HTTPBindAddress), viper.GetInt(config.HTTPBindPort)), 25 | ) 26 | 27 | s.Start() 28 | } 29 | -------------------------------------------------------------------------------- /cmd/superuser/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "time" 8 | 9 | "github.com/alexferl/golib/config" 10 | "github.com/alexferl/golib/database/mongodb" 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/spf13/pflag" 14 | "github.com/spf13/viper" 15 | 16 | "github.com/alexferl/echo-boilerplate/data" 17 | "github.com/alexferl/echo-boilerplate/mappers" 18 | "github.com/alexferl/echo-boilerplate/models" 19 | "github.com/alexferl/echo-boilerplate/services" 20 | ) 21 | 22 | type Config struct { 23 | Config *config.Config 24 | MongoDB *mongodb.Config 25 | Super *Super 26 | } 27 | 28 | type Super struct { 29 | Email string 30 | Name string 31 | Password string 32 | Username string 33 | } 34 | 35 | func New() *Config { 36 | return &Config{ 37 | Config: config.New("APP"), 38 | MongoDB: mongodb.DefaultConfig, 39 | Super: &Super{ 40 | Email: "super@example.com", 41 | Name: "Super", 42 | Password: "", 43 | Username: "super", 44 | }, 45 | } 46 | } 47 | 48 | const ( 49 | SuperEmail = "email" 50 | SuperName = "name" 51 | SuperPassword = "password" 52 | SuperUsername = "username" 53 | ) 54 | 55 | func (c *Config) addFlags(fs *pflag.FlagSet) { 56 | fs.StringVar(&c.Super.Email, SuperEmail, c.Super.Email, "Superuser email") 57 | fs.StringVar(&c.Super.Name, SuperName, c.Super.Name, "Superuser display name") 58 | fs.StringVar(&c.Super.Password, SuperPassword, c.Super.Password, "Superuser password") 59 | fs.StringVar(&c.Super.Username, SuperUsername, c.Super.Username, "Superuser name") 60 | } 61 | 62 | func (c *Config) BindFlags() { 63 | c.addFlags(pflag.CommandLine) 64 | c.MongoDB.BindFlags(pflag.CommandLine) 65 | 66 | err := c.Config.BindFlags() 67 | if err != nil { 68 | log.Fatal().Err(err).Msg("failed binding flags") 69 | } 70 | 71 | if viper.GetString(SuperPassword) == "" { 72 | log.Fatal().Msg("password is unset!") 73 | } 74 | } 75 | 76 | func main() { 77 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 78 | 79 | c := New() 80 | c.BindFlags() 81 | 82 | client, err := data.MewMongoClient() 83 | if err != nil { 84 | log.Fatal().Err(err).Msg("failed creating mongo client") 85 | } 86 | 87 | mapper := mappers.NewUser(client) 88 | svc := services.NewUser(mapper) 89 | 90 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 91 | defer cancel() 92 | 93 | res, err := svc.FindOneByEmailOrUsername(ctx, viper.GetString(SuperEmail), viper.GetString(SuperUsername)) 94 | if res != nil { 95 | log.Fatal().Msg("username or email already in-use") 96 | } 97 | if err != nil { 98 | var se *services.Error 99 | if errors.As(err, &se) { 100 | if se.Kind == services.NotExist { 101 | email := viper.GetString(SuperEmail) 102 | name := viper.GetString(SuperName) 103 | username := viper.GetString(SuperUsername) 104 | 105 | log.Info(). 106 | Str("name", name). 107 | Str("username", username). 108 | Str("email", email). 109 | Msg("creating superuser") 110 | 111 | user := models.NewUserWithRole(email, username, models.SuperRole) 112 | user.Name = name 113 | 114 | err = user.SetPassword(viper.GetString(SuperPassword)) 115 | if err != nil { 116 | log.Fatal().Err(err).Msg("failed setting superuser password") 117 | } 118 | 119 | _, err = svc.Create(ctx, user) 120 | if err != nil { 121 | log.Fatal().Err(err).Msg("failed creating superuser") 122 | } 123 | 124 | log.Info().Msg("done") 125 | return 126 | } 127 | } 128 | log.Fatal().Err(err).Msg("failed getting superuser") 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 50..75 3 | round: down 4 | precision: 2 5 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | libConfig "github.com/alexferl/golib/config" 8 | libMongo "github.com/alexferl/golib/database/mongodb" 9 | libHttp "github.com/alexferl/golib/http/api/config" 10 | libLog "github.com/alexferl/golib/log" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | // Config holds all configuration for our program 17 | type Config struct { 18 | Config *libConfig.Config 19 | HTTP *libHttp.Config 20 | Logging *libLog.Config 21 | MongoDB *libMongo.Config 22 | 23 | BaseURL string 24 | 25 | Casbin *Casbin 26 | Cookies *Cookies 27 | CSRF *CSRF 28 | JWT *JWT 29 | OAuth2 *OAuth2 30 | OAuth2Google *OAuth2Google 31 | OpenAPI *OpenAPI 32 | } 33 | 34 | type Casbin struct { 35 | Model string 36 | Policy string 37 | } 38 | 39 | type Cookies struct { 40 | Enabled bool 41 | Domain string 42 | } 43 | 44 | type CSRF struct { 45 | Enabled bool 46 | SecretKey string 47 | CookieName string 48 | CookieDomain string 49 | HeaderName string 50 | } 51 | 52 | type JWT struct { 53 | AccessTokenExpiry time.Duration 54 | AccessTokenCookieName string 55 | RefreshTokenExpiry time.Duration 56 | RefreshTokenCookieName string 57 | PrivateKey string 58 | Issuer string 59 | } 60 | 61 | type OAuth2 struct { 62 | Providers []string 63 | } 64 | 65 | type OAuth2Google struct { 66 | ClientId string 67 | ClientSecret string 68 | } 69 | 70 | type OpenAPI struct { 71 | Schema string 72 | } 73 | 74 | // New creates a Config instance 75 | func New() *Config { 76 | c := &Config{ 77 | Config: libConfig.New("APP"), 78 | HTTP: libHttp.DefaultConfig, 79 | Logging: libLog.DefaultConfig, 80 | MongoDB: libMongo.DefaultConfig, 81 | BaseURL: "http://localhost:1323", 82 | Casbin: &Casbin{ 83 | Model: "./casbin/model.conf", 84 | Policy: "./casbin/policy.csv", 85 | }, 86 | Cookies: &Cookies{ 87 | Enabled: false, 88 | Domain: "", 89 | }, 90 | CSRF: &CSRF{ 91 | Enabled: false, 92 | CookieDomain: "", 93 | CookieName: "csrf_token", 94 | HeaderName: "X-CSRF-Token", 95 | SecretKey: "", 96 | }, 97 | JWT: &JWT{ 98 | AccessTokenCookieName: "access_token", 99 | AccessTokenExpiry: 60 * time.Minute, 100 | PrivateKey: "./private-key.pem", 101 | RefreshTokenCookieName: "refresh_token", 102 | RefreshTokenExpiry: (30 * 24) * time.Hour, 103 | }, 104 | OAuth2: &OAuth2{ 105 | Providers: []string{""}, 106 | }, 107 | OAuth2Google: &OAuth2Google{ 108 | ClientId: "", 109 | ClientSecret: "", 110 | }, 111 | OpenAPI: &OpenAPI{ 112 | Schema: "./openapi/openapi.yaml", 113 | }, 114 | } 115 | c.JWT.Issuer = c.BaseURL 116 | return c 117 | } 118 | 119 | const ( 120 | AppName = libConfig.AppName 121 | EnvName = libConfig.EnvName 122 | 123 | HTTPBindAddress = libHttp.HTTPBindAddress 124 | HTTPBindPort = libHttp.HTTPBindPort 125 | 126 | BaseURL = "base-url" 127 | 128 | CasbinModel = "casbin-model" 129 | CasbinPolicy = "casbin-policy" 130 | 131 | CookiesEnabled = "cookies-enabled" 132 | CookiesDomain = "cookies-domain" 133 | 134 | CSRFEnabled = "csrf-enabled" 135 | CSRFCookieDomain = "csrf-cookie-domain" 136 | CSRFCookieName = "csrf-cookie-name" 137 | CSRFHeaderName = "csrf-header-name" 138 | CSRFSecretKey = "csrf-secret-key" 139 | 140 | JWTAccessTokenCookieName = "jwt-access-token-cookie-name" 141 | JWTAccessTokenExpiry = "jwt-access-token-expiry" 142 | JWTIssuer = "jwt-issuer" 143 | JWTPrivateKey = "jwt-private-key" 144 | JWTRefreshTokenCookieName = "jwt-refresh-token-cookie-name" 145 | JWTRefreshTokenExpiry = "jwt-refresh-token-expiry" 146 | 147 | OAuth2Providers = "oauth2-providers" 148 | 149 | OAuth2GoogleClientId = "oauth2-google-client-id" 150 | OAuth2GoogleClientSecret = "oauth2-google-client-secret" 151 | 152 | OpenAPISchema = "openapi-schema" 153 | ) 154 | 155 | // addFlags adds all the flags from the command line 156 | func (c *Config) addFlags(fs *pflag.FlagSet) { 157 | fs.StringVar(&c.BaseURL, BaseURL, c.BaseURL, "Base URL where the app will be served") 158 | 159 | fs.StringVar(&c.Casbin.Model, CasbinModel, c.Casbin.Model, "Casbin model file") 160 | fs.StringVar(&c.Casbin.Policy, CasbinPolicy, c.Casbin.Policy, "Casbin policy file") 161 | 162 | fs.BoolVar(&c.Cookies.Enabled, CookiesEnabled, c.Cookies.Enabled, "Send cookies with authentication requests") 163 | fs.StringVar(&c.Cookies.Domain, CookiesDomain, c.Cookies.Domain, "Cookies domain") 164 | 165 | fs.BoolVar(&c.CSRF.Enabled, CSRFEnabled, c.CSRF.Enabled, "CSRF enabled") 166 | fs.StringVar(&c.CSRF.SecretKey, CSRFSecretKey, c.CSRF.SecretKey, "CSRF secret used to hash the token") 167 | fs.StringVar(&c.CSRF.CookieName, CSRFCookieName, c.CSRF.CookieName, "CSRF cookie name") 168 | fs.StringVar(&c.CSRF.CookieDomain, CSRFCookieDomain, c.CSRF.CookieDomain, "CSRF cookie domain") 169 | fs.StringVar(&c.CSRF.HeaderName, CSRFHeaderName, c.CSRF.HeaderName, "CSRF header name") 170 | 171 | fs.StringVar(&c.JWT.AccessTokenCookieName, JWTAccessTokenCookieName, c.JWT.AccessTokenCookieName, 172 | "JWT access token cookie name") 173 | fs.DurationVar(&c.JWT.AccessTokenExpiry, JWTAccessTokenExpiry, c.JWT.AccessTokenExpiry, 174 | "JWT access token expiry") 175 | fs.StringVar(&c.JWT.Issuer, JWTIssuer, c.JWT.Issuer, "JWT issuer") 176 | fs.StringVar(&c.JWT.PrivateKey, JWTPrivateKey, c.JWT.PrivateKey, "JWT private key file path") 177 | fs.StringVar(&c.JWT.RefreshTokenCookieName, JWTRefreshTokenCookieName, c.JWT.RefreshTokenCookieName, 178 | "JWT refresh token cookie name") 179 | fs.DurationVar(&c.JWT.RefreshTokenExpiry, JWTRefreshTokenExpiry, c.JWT.RefreshTokenExpiry, 180 | "JWT refresh token expiry") 181 | 182 | fs.StringSliceVar(&c.OAuth2.Providers, OAuth2Providers, c.OAuth2.Providers, "OAuth2 providers") 183 | 184 | fs.StringVar(&c.OAuth2Google.ClientId, OAuth2GoogleClientId, c.OAuth2Google.ClientId, "OAuth2 Google client id") 185 | fs.StringVar(&c.OAuth2Google.ClientSecret, OAuth2GoogleClientSecret, c.OAuth2Google.ClientSecret, "OAuth2 Google client secret") 186 | 187 | fs.StringVar(&c.OpenAPI.Schema, OpenAPISchema, c.OpenAPI.Schema, "OpenAPI schema file") 188 | } 189 | 190 | func (c *Config) BindFlags() { 191 | if pflag.Parsed() { 192 | return 193 | } 194 | 195 | c.addFlags(pflag.CommandLine) 196 | c.Logging.BindFlags(pflag.CommandLine) 197 | c.HTTP.BindFlags(pflag.CommandLine) 198 | c.MongoDB.BindFlags(pflag.CommandLine) 199 | 200 | err := c.Config.BindFlagsWithConfigPaths() 201 | if err != nil { 202 | panic(fmt.Errorf("failed binding flags: %v", err)) 203 | } 204 | 205 | err = libLog.New(&libLog.Config{ 206 | LogLevel: viper.GetString(libLog.LogLevel), 207 | LogOutput: viper.GetString(libLog.LogOutput), 208 | LogWriter: viper.GetString(libLog.LogWriter), 209 | }) 210 | if err != nil { 211 | panic(fmt.Errorf("failed creating logger: %v", err)) 212 | } 213 | 214 | if viper.GetBool(CSRFEnabled) && viper.GetString(CSRFSecretKey) == "" { 215 | log.Panic().Msg("CSRF: secret key is unset!") 216 | } 217 | 218 | if viper.GetBool(libHttp.HTTPCORSEnabled) { 219 | for _, origin := range viper.GetStringSlice(libHttp.HTTPCORSAllowOrigins) { 220 | if origin == "*" { 221 | log.Warn().Msg("CORS: using '*' in Access-Control-Allow-Origin is potentially unsafe!") 222 | } 223 | 224 | if origin == "null" { 225 | log.Warn().Msg("CORS: using 'null' in Access-Control-Allow-Origin is unsafe and should not be used!") 226 | } 227 | 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /configs/config.local.toml: -------------------------------------------------------------------------------- 1 | env-name = "local" 2 | log-level = "DEBUG" 3 | 4 | cookies-enabled = true 5 | csrf-enabled = false 6 | csrf-secret-key = "changeme" 7 | 8 | http-cors-enabled = true 9 | http-cors-allow-credentials = true 10 | http-cors-allow-origins = ["*"] # TODO: change to the web app's domain 11 | -------------------------------------------------------------------------------- /configs/config.prod.toml: -------------------------------------------------------------------------------- 1 | http-bind-address = "0.0.0.0" 2 | env-name = "prod" 3 | log-writer = "json" 4 | -------------------------------------------------------------------------------- /data/db.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/alexferl/golib/database/mongodb" 8 | "github.com/spf13/viper" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | 13 | "github.com/alexferl/echo-boilerplate/config" 14 | ) 15 | 16 | func MewMongoClient() (*mongo.Client, error) { 17 | client, err := mongodb.New() 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | err = createIndexes(client) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return client, nil 28 | } 29 | 30 | func createIndexes(client *mongo.Client) error { 31 | indexes := map[string][]mongo.IndexModel{} 32 | 33 | username := "username" 34 | email := "email" 35 | t := true 36 | 37 | indexes["users"] = []mongo.IndexModel{ 38 | { 39 | Keys: bson.D{{"username", 1}}, 40 | Options: &options.IndexOptions{ 41 | Name: &username, 42 | Unique: &t, 43 | Collation: &options.Collation{Locale: "en", Strength: 2}, 44 | }, 45 | }, 46 | { 47 | Keys: bson.D{{"email", 1}}, 48 | Options: &options.IndexOptions{ 49 | Name: &email, 50 | Unique: &t, 51 | Collation: &options.Collation{Locale: "en", Strength: 2}, 52 | }, 53 | }, 54 | { 55 | Keys: bson.D{ 56 | {"id", 1}, 57 | }, 58 | Options: &options.IndexOptions{ 59 | Unique: &t, 60 | }, 61 | }, 62 | } 63 | 64 | indexes["tasks"] = []mongo.IndexModel{ 65 | { 66 | Keys: bson.D{ 67 | {"id", 1}, 68 | }, 69 | Options: &options.IndexOptions{ 70 | Unique: &t, 71 | }, 72 | }, 73 | { 74 | Keys: bson.D{ 75 | {"title", "text"}, 76 | }, 77 | }, 78 | } 79 | 80 | indexes["personal_access_tokens"] = []mongo.IndexModel{ 81 | { 82 | Keys: bson.D{ 83 | {"id", 1}, 84 | }, 85 | Options: &options.IndexOptions{ 86 | Unique: &t, 87 | }, 88 | }, 89 | { 90 | Keys: bson.D{ 91 | {"user_id", 1}, 92 | }, 93 | }, 94 | { 95 | Keys: bson.D{ 96 | {"user_id", 1}, 97 | {"name", 1}, 98 | }, 99 | Options: &options.IndexOptions{ 100 | Unique: &t, 101 | }, 102 | }, 103 | } 104 | 105 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 106 | defer cancel() 107 | 108 | db := client.Database(viper.GetString(config.AppName)) 109 | 110 | opts := options.Update().SetUpsert(true) 111 | _, err := db.Collection("counters").UpdateOne( 112 | ctx, 113 | bson.D{ 114 | {"_id", "tasks"}, 115 | {"seq", bson.D{{"$exists", false}}}, 116 | }, 117 | bson.D{{"$inc", bson.D{{"seq", 1}}}}, 118 | opts, 119 | ) 120 | if err != nil { 121 | if !mongo.IsDuplicateKeyError(err) { 122 | panic(err) 123 | } 124 | } 125 | 126 | return mongodb.CreateIndexes(ctx, db, indexes) 127 | } 128 | -------------------------------------------------------------------------------- /data/mapper.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | 8 | "github.com/rs/zerolog/log" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | "go.mongodb.org/mongo-driver/mongo/readconcern" 13 | "go.mongodb.org/mongo-driver/mongo/writeconcern" 14 | ) 15 | 16 | var ErrNoDocuments = errors.New("no documents in result") 17 | 18 | type Mapper interface { 19 | Aggregate(ctx context.Context, pipeline mongo.Pipeline, results any, opts ...*options.AggregateOptions) (any, error) 20 | Count(ctx context.Context, filter any, opts ...*options.CountOptions) (int64, error) 21 | Find(ctx context.Context, filter any, results any, opts ...*options.FindOptions) (any, error) 22 | FindOne(ctx context.Context, filter any, result any, opts ...*options.FindOneOptions) (any, error) 23 | FindOneAndUpdate(ctx context.Context, filter any, update any, result any, opts ...*options.FindOneAndUpdateOptions) (any, error) 24 | InsertOne(ctx context.Context, document any, opts ...*options.InsertOneOptions) (*mongo.InsertOneResult, error) 25 | UpdateOne(ctx context.Context, filter any, update any, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) 26 | GetNextSequence(ctx context.Context, name string) (*Sequence, error) 27 | } 28 | 29 | type mapper struct { 30 | client *mongo.Client 31 | db *mongo.Database 32 | dbName string 33 | collection *mongo.Collection 34 | } 35 | 36 | func NewMapper(client *mongo.Client, databaseName string, collectionName string) Mapper { 37 | db := client.Database(databaseName) 38 | collection := db.Collection(collectionName) 39 | return &mapper{ 40 | client, 41 | db, 42 | databaseName, 43 | collection, 44 | } 45 | } 46 | 47 | func (m *mapper) Aggregate(ctx context.Context, pipeline mongo.Pipeline, results any, opts ...*options.AggregateOptions) (any, error) { 48 | cur, err := m.collection.Aggregate(ctx, pipeline, opts...) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | defer func(cur *mongo.Cursor, ctx context.Context) { 54 | err := cur.Close(ctx) 55 | if err != nil { 56 | log.Error().Err(err).Msg("cursor error") 57 | } 58 | }(cur, ctx) 59 | 60 | err = cur.All(ctx, &results) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | if err = cur.Err(); err != nil { 66 | return nil, err 67 | } 68 | 69 | return results, nil 70 | } 71 | 72 | func (m *mapper) Count(ctx context.Context, filter any, opts ...*options.CountOptions) (int64, error) { 73 | if filter == nil { 74 | filter = bson.D{} 75 | } 76 | 77 | count, err := m.collection.CountDocuments(ctx, filter, opts...) 78 | if err != nil { 79 | return 0, err 80 | } 81 | 82 | return count, nil 83 | } 84 | 85 | func (m *mapper) Find(ctx context.Context, filter any, results any, opts ...*options.FindOptions) (any, error) { 86 | if filter == nil { 87 | filter = bson.D{} 88 | } 89 | 90 | cur, err := m.collection.Find(ctx, filter, opts...) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | defer func(cur *mongo.Cursor, ctx context.Context) { 96 | err := cur.Close(ctx) 97 | if err != nil { 98 | log.Error().Err(err).Msg("cursor error") 99 | } 100 | }(cur, ctx) 101 | 102 | err = cur.All(ctx, &results) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | if err = cur.Err(); err != nil { 108 | return nil, err 109 | } 110 | 111 | return results, nil 112 | } 113 | 114 | func (m *mapper) FindOne(ctx context.Context, filter any, result any, opts ...*options.FindOneOptions) (any, error) { 115 | err := m.collection.FindOne(ctx, filter, opts...).Decode(result) 116 | if errors.Is(err, mongo.ErrNoDocuments) { 117 | return nil, ErrNoDocuments 118 | } else if err != nil { 119 | return nil, err 120 | } 121 | 122 | return result, nil 123 | } 124 | 125 | func (m *mapper) FindOneAndUpdate(ctx context.Context, filter any, update any, result any, opts ...*options.FindOneAndUpdateOptions) (any, error) { 126 | opts = append(opts, options.FindOneAndUpdate().SetReturnDocument(options.After)) 127 | res := m.collection.FindOneAndUpdate(ctx, filter, bson.D{{"$set", update}}, opts...) 128 | if res.Err() != nil { 129 | return nil, res.Err() 130 | } 131 | 132 | if result != nil { 133 | err := res.Decode(result) 134 | if err != nil { 135 | return nil, err 136 | } 137 | } 138 | 139 | return result, nil 140 | } 141 | 142 | func (m *mapper) InsertOne(ctx context.Context, document any, opts ...*options.InsertOneOptions) (*mongo.InsertOneResult, error) { 143 | res, err := m.collection.InsertOne(ctx, document, opts...) 144 | return res, err 145 | } 146 | 147 | func (m *mapper) UpdateOne(ctx context.Context, filter any, update any, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) { 148 | res, err := m.collection.UpdateOne(ctx, filter, update, opts...) 149 | if err != nil { 150 | return nil, err 151 | } 152 | return res, nil 153 | } 154 | 155 | type Sequence struct { 156 | Seq int `bson:"seq"` 157 | } 158 | 159 | func (s *Sequence) String() string { 160 | return strconv.Itoa(s.Seq) 161 | } 162 | 163 | func (m *mapper) GetNextSequence(ctx context.Context, name string) (*Sequence, error) { 164 | opts := options.FindOneAndUpdate().SetUpsert(true) 165 | res := m.db.Collection("counters").FindOneAndUpdate( 166 | ctx, 167 | bson.D{{"_id", name}}, 168 | bson.D{{"$inc", bson.D{{"seq", 1}}}}, 169 | opts, 170 | ) 171 | if res.Err() != nil { 172 | return nil, res.Err() 173 | } 174 | 175 | seq := &Sequence{} 176 | err := res.Decode(seq) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | return seq, nil 182 | } 183 | 184 | func (m *mapper) getSession() (mongo.Session, *options.TransactionOptions, error) { 185 | wc := writeconcern.Majority() 186 | rc := readconcern.Snapshot() 187 | txnOpts := options.Transaction().SetWriteConcern(wc).SetReadConcern(rc) 188 | 189 | session, err := m.client.StartSession() 190 | if err != nil { 191 | return nil, nil, err 192 | } 193 | 194 | return session, txnOpts, nil 195 | } 196 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexferl/echo-boilerplate 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/alexferl/echo-casbin v1.0.0 7 | github.com/alexferl/echo-jwt v1.2.0 8 | github.com/alexferl/echo-openapi v1.1.0 9 | github.com/alexferl/golib/config v0.0.0-20240228040247-93f62184757c 10 | github.com/alexferl/golib/database/mongodb v0.0.0-20240228040247-93f62184757c 11 | github.com/alexferl/golib/http/api v0.0.0-20240228040247-93f62184757c 12 | github.com/alexferl/golib/log v0.0.0-20240228040247-93f62184757c 13 | github.com/alexferl/httplink v0.1.0 14 | github.com/casbin/casbin/v2 v2.84.1 15 | github.com/labstack/echo/v4 v4.11.4 16 | github.com/lestrrat-go/jwx/v2 v2.0.21 17 | github.com/matthewhartstonge/argon2 v1.0.0 18 | github.com/rs/xid v1.5.0 19 | github.com/rs/zerolog v1.32.0 20 | github.com/spf13/pflag v1.0.5 21 | github.com/spf13/viper v1.18.2 22 | github.com/stretchr/testify v1.9.0 23 | go.mongodb.org/mongo-driver v1.14.0 24 | go.uber.org/automaxprocs v1.5.3 25 | golang.org/x/oauth2 v0.18.0 26 | ) 27 | 28 | require ( 29 | cloud.google.com/go/compute v1.25.0 // indirect 30 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 31 | github.com/casbin/govaluate v1.1.1 // indirect 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 33 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 34 | github.com/fsnotify/fsnotify v1.7.0 // indirect 35 | github.com/getkin/kin-openapi v0.123.0 // indirect 36 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 37 | github.com/go-openapi/swag v0.23.0 // indirect 38 | github.com/goccy/go-json v0.10.2 // indirect 39 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 40 | github.com/golang/protobuf v1.5.4 // indirect 41 | github.com/golang/snappy v0.0.4 // indirect 42 | github.com/gorilla/mux v1.8.1 // indirect 43 | github.com/hashicorp/hcl v1.0.0 // indirect 44 | github.com/invopop/yaml v0.2.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/klauspost/compress v1.17.7 // indirect 47 | github.com/labstack/gommon v0.4.2 // indirect 48 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 49 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 50 | github.com/lestrrat-go/httprc v1.0.5 // indirect 51 | github.com/lestrrat-go/iter v1.0.2 // indirect 52 | github.com/lestrrat-go/option v1.0.1 // indirect 53 | github.com/magiconair/properties v1.8.7 // indirect 54 | github.com/mailru/easyjson v0.7.7 // indirect 55 | github.com/mattn/go-colorable v0.1.13 // indirect 56 | github.com/mattn/go-isatty v0.0.20 // indirect 57 | github.com/mitchellh/mapstructure v1.5.0 // indirect 58 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 59 | github.com/montanaflynn/stats v0.7.1 // indirect 60 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 61 | github.com/perimeterx/marshmallow v1.1.5 // indirect 62 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 63 | github.com/sagikazarmark/locafero v0.4.0 // indirect 64 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 65 | github.com/segmentio/asm v1.2.0 // indirect 66 | github.com/sourcegraph/conc v0.3.0 // indirect 67 | github.com/spf13/afero v1.11.0 // indirect 68 | github.com/spf13/cast v1.6.0 // indirect 69 | github.com/stretchr/objx v0.5.2 // indirect 70 | github.com/subosito/gotenv v1.6.0 // indirect 71 | github.com/valyala/bytebufferpool v1.0.0 // indirect 72 | github.com/valyala/fasttemplate v1.2.2 // indirect 73 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 74 | github.com/xdg-go/scram v1.1.2 // indirect 75 | github.com/xdg-go/stringprep v1.0.4 // indirect 76 | github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect 77 | go.uber.org/multierr v1.11.0 // indirect 78 | golang.org/x/crypto v0.21.0 // indirect 79 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 80 | golang.org/x/net v0.22.0 // indirect 81 | golang.org/x/sync v0.6.0 // indirect 82 | golang.org/x/sys v0.18.0 // indirect 83 | golang.org/x/text v0.14.0 // indirect 84 | golang.org/x/time v0.5.0 // indirect 85 | google.golang.org/appengine v1.6.8 // indirect 86 | google.golang.org/protobuf v1.33.0 // indirect 87 | gopkg.in/ini.v1 v1.67.0 // indirect 88 | gopkg.in/yaml.v3 v3.0.1 // indirect 89 | ) 90 | -------------------------------------------------------------------------------- /handlers/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "slices" 8 | "time" 9 | 10 | "github.com/alexferl/echo-openapi" 11 | "github.com/alexferl/golib/http/api/server" 12 | "github.com/labstack/echo/v4" 13 | jwx "github.com/lestrrat-go/jwx/v2/jwt" 14 | "github.com/rs/zerolog/log" 15 | "github.com/spf13/viper" 16 | 17 | "github.com/alexferl/echo-boilerplate/config" 18 | "github.com/alexferl/echo-boilerplate/models" 19 | "github.com/alexferl/echo-boilerplate/services" 20 | "github.com/alexferl/echo-boilerplate/util/cookie" 21 | ) 22 | 23 | type AuthHandler struct { 24 | *openapi.Handler 25 | svc UserService 26 | } 27 | 28 | func NewAuthHandler(openapi *openapi.Handler, svc UserService) *AuthHandler { 29 | return &AuthHandler{ 30 | Handler: openapi, 31 | svc: svc, 32 | } 33 | } 34 | 35 | func (h *AuthHandler) Register(s *server.Server) { 36 | s.Add(http.MethodPost, "/auth/login", h.login) 37 | s.Add(http.MethodPost, "/auth/logout", h.logout) 38 | s.Add(http.MethodPost, "/auth/refresh", h.refresh) 39 | s.Add(http.MethodPost, "/auth/signup", h.signup) 40 | s.Add(http.MethodGet, "/auth/token", h.token) 41 | 42 | if slices.Contains(viper.GetStringSlice(config.OAuth2Providers), "google") { 43 | s.Add(http.MethodGet, "/oauth2/google/callback", h.oauth2GoogleCallback) 44 | s.Add(http.MethodGet, "/oauth2/google/login", h.oauth2GoogleLogin) 45 | } 46 | } 47 | 48 | type LoginRequest struct { 49 | Email string `json:"email,omitempty"` 50 | Password string `json:"password"` 51 | Username string `json:"username,omitempty"` 52 | } 53 | 54 | type LoginResponse struct { 55 | AccessToken string `json:"access_token"` 56 | ExpiresIn int64 `json:"expires_in"` 57 | RefreshToken string `json:"refresh_token"` 58 | TokenType string `json:"token_type"` 59 | } 60 | 61 | func (h *AuthHandler) login(c echo.Context) error { 62 | body := &LoginRequest{} 63 | if err := c.Bind(body); err != nil { 64 | log.Error().Err(err).Msg("failed binding body") 65 | return err 66 | } 67 | 68 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 69 | defer cancel() 70 | 71 | user, err := h.svc.FindOneByEmailOrUsername(ctx, body.Email, body.Username) 72 | if err != nil { 73 | var se *services.Error 74 | if errors.As(err, &se) { 75 | if se.Kind == services.NotExist { 76 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "invalid email or password"}) 77 | } 78 | } 79 | log.Error().Err(err).Msg("failed finding user") 80 | return err 81 | } 82 | 83 | err = user.ValidatePassword(body.Password) 84 | if err != nil { 85 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "invalid email or password"}) 86 | } 87 | 88 | access, refresh, err := user.Login() 89 | if err != nil { 90 | log.Error().Err(err).Msg("failed generating tokens") 91 | return err 92 | } 93 | 94 | _, err = h.svc.Update(ctx, "", user) 95 | if err != nil { 96 | log.Error().Err(err).Msg("failed updating user") 97 | return err 98 | } 99 | 100 | if viper.GetBool(config.CookiesEnabled) { 101 | cookie.SetToken(c, access, refresh) 102 | } 103 | 104 | resp := &LoginResponse{ 105 | AccessToken: string(access), 106 | ExpiresIn: int64(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), 107 | RefreshToken: string(refresh), 108 | TokenType: "Bearer", 109 | } 110 | 111 | return h.Validate(c, http.StatusOK, resp) 112 | } 113 | 114 | type LogoutRequest struct { 115 | RefreshToken string `json:"refresh_token"` 116 | } 117 | 118 | func (h *AuthHandler) logout(c echo.Context) error { 119 | currentUser := c.Get("user").(*models.User) 120 | encodedToken := c.Get("refresh_token_encoded").(string) 121 | 122 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 123 | defer cancel() 124 | 125 | user, err := h.svc.Read(ctx, currentUser.Id) 126 | if err != nil { 127 | var se *services.Error 128 | if errors.As(err, &se) { 129 | if se.Kind == services.NotExist { 130 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "token not found"}) 131 | } 132 | } 133 | log.Error().Err(err).Msg("failed getting user") 134 | return err 135 | } 136 | 137 | if err = user.ValidateRefreshToken(encodedToken); err != nil { 138 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "token mismatch"}) 139 | } 140 | 141 | user.Logout() 142 | 143 | _, err = h.svc.Update(ctx, "", user) 144 | if err != nil { 145 | log.Error().Err(err).Msg("failed updating user") 146 | return err 147 | } 148 | 149 | cookie.SetExpiredToken(c) 150 | 151 | return h.Validate(c, http.StatusNoContent, nil) 152 | } 153 | 154 | type RefreshRequest struct { 155 | GrantType string `json:"grant_type"` 156 | RefreshToken string `json:"refresh_token"` 157 | } 158 | 159 | type RefreshResponse struct { 160 | AccessToken string `json:"access_token"` 161 | ExpiresIn int64 `json:"expires_in"` 162 | RefreshToken string `json:"refresh_token"` 163 | TokenType string `json:"token_type"` 164 | } 165 | 166 | func (h *AuthHandler) refresh(c echo.Context) error { 167 | token := c.Get("refresh_token").(jwx.Token) 168 | encodedToken := c.Get("refresh_token_encoded").(string) 169 | 170 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 171 | defer cancel() 172 | 173 | user, err := h.svc.Read(ctx, token.Subject()) 174 | if err != nil { 175 | var se *services.Error 176 | if errors.As(err, &se) { 177 | if se.Kind == services.NotExist { 178 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "token not found"}) 179 | } 180 | } 181 | log.Error().Err(err).Msg("failed getting user") 182 | return err 183 | } 184 | 185 | if err = user.ValidateRefreshToken(encodedToken); err != nil { 186 | return h.Validate(c, http.StatusUnauthorized, echo.Map{"message": "token mismatch"}) 187 | } 188 | 189 | access, refresh, err := user.Refresh() 190 | if err != nil { 191 | log.Error().Err(err).Msg("failed generating tokens") 192 | return err 193 | } 194 | 195 | _, err = h.svc.Update(ctx, "", user) 196 | if err != nil { 197 | log.Error().Err(err).Msg("failed updating user") 198 | return err 199 | } 200 | 201 | if viper.GetBool(config.CookiesEnabled) { 202 | cookie.SetToken(c, access, refresh) 203 | } 204 | 205 | resp := &RefreshResponse{ 206 | AccessToken: string(access), 207 | ExpiresIn: int64(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), 208 | RefreshToken: string(refresh), 209 | TokenType: "Bearer", 210 | } 211 | 212 | return h.Validate(c, http.StatusOK, resp) 213 | } 214 | 215 | type SignUpRequest struct { 216 | Email string `json:"email"` 217 | Username string `json:"username"` 218 | Name string `json:"name"` 219 | Bio string `json:"bio"` 220 | Password string `json:"password"` 221 | } 222 | 223 | func (h *AuthHandler) signup(c echo.Context) error { 224 | body := &SignUpRequest{} 225 | if err := c.Bind(body); err != nil { 226 | log.Error().Err(err).Msg("failed binding body") 227 | return err 228 | } 229 | 230 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 231 | defer cancel() 232 | 233 | res, err := h.svc.FindOneByEmailOrUsername(ctx, body.Email, body.Username) 234 | if err != nil { 235 | var se *services.Error 236 | if errors.As(err, &se) { 237 | if se.Kind == services.Exist { 238 | return h.Validate(c, http.StatusConflict, echo.Map{"message": se.Message}) 239 | } 240 | } else { 241 | log.Error().Err(err).Msg("failed getting user") 242 | } 243 | } 244 | 245 | user := models.NewUser(body.Email, body.Username) 246 | user.Name = body.Name 247 | user.Bio = body.Bio 248 | err = user.SetPassword(body.Password) 249 | if err != nil { 250 | log.Error().Err(err).Msg("failed setting password") 251 | return err 252 | } 253 | 254 | user.Create(user.Id) 255 | 256 | res, err = h.svc.Create(ctx, user) 257 | if err != nil { 258 | var se *services.Error 259 | if errors.As(err, &se) { 260 | if se.Kind == services.Exist { 261 | return h.Validate(c, http.StatusConflict, echo.Map{"message": se.Message}) 262 | } 263 | } else { 264 | log.Error().Err(err).Msg("failed inserting new user") 265 | } 266 | return err 267 | } 268 | 269 | return h.Validate(c, http.StatusOK, res.Response()) 270 | } 271 | 272 | type TokenResponse struct { 273 | Exp time.Time `json:"exp"` 274 | Iat time.Time `json:"iat"` 275 | Iss string `json:"iss"` 276 | Nbf time.Time `json:"nbf"` 277 | Roles []string `json:"roles"` 278 | Sub string `json:"sub"` 279 | Type string `json:"type"` 280 | } 281 | 282 | func (h *AuthHandler) token(c echo.Context) error { 283 | token := c.Get("token").(jwx.Token) 284 | 285 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 286 | defer cancel() 287 | 288 | m, err := token.AsMap(ctx) 289 | if err != nil { 290 | log.Error().Err(err).Msg("failed converting token to map") 291 | return err 292 | } 293 | 294 | return h.Validate(c, http.StatusOK, m) 295 | } 296 | -------------------------------------------------------------------------------- /handlers/handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/alexferl/golib/http/api/server" 5 | ) 6 | 7 | type Handler interface { 8 | Register(s *server.Server) 9 | } 10 | -------------------------------------------------------------------------------- /handlers/oauth2_google.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/labstack/echo/v4" 13 | "github.com/rs/zerolog/log" 14 | "github.com/spf13/viper" 15 | "golang.org/x/oauth2" 16 | "golang.org/x/oauth2/google" 17 | 18 | "github.com/alexferl/echo-boilerplate/config" 19 | "github.com/alexferl/echo-boilerplate/models" 20 | "github.com/alexferl/echo-boilerplate/services" 21 | "github.com/alexferl/echo-boilerplate/util/cookie" 22 | "github.com/alexferl/echo-boilerplate/util/rand" 23 | ) 24 | 25 | type GoogleUser struct { 26 | Id string `json:"id"` 27 | Email string `json:"email"` 28 | VerifiedEmail bool `json:"verified_email"` 29 | Picture string `json:"picture"` 30 | } 31 | 32 | func getOAuth2GoogleConfig() *oauth2.Config { 33 | return &oauth2.Config{ 34 | ClientID: viper.GetString(config.OAuth2GoogleClientId), 35 | ClientSecret: viper.GetString(config.OAuth2GoogleClientSecret), 36 | Endpoint: google.Endpoint, 37 | RedirectURL: fmt.Sprintf("%s/oauth2/google/callback", viper.GetString(config.BaseURL)), 38 | Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"}, 39 | } 40 | } 41 | 42 | func (h *AuthHandler) oauth2GoogleLogin(c echo.Context) error { 43 | state, err := rand.GenerateRandomString(80) 44 | if err != nil { 45 | return fmt.Errorf("failed generating state: %v", err) 46 | } 47 | 48 | url := getOAuth2GoogleConfig().AuthCodeURL(state) 49 | opts := &cookie.Options{ 50 | Name: "state", 51 | Value: state, 52 | Path: "/oauth2/google/callback", 53 | SameSite: http.SameSiteLaxMode, // needs to be Lax since it's across domains 54 | HttpOnly: true, 55 | MaxAge: 600, 56 | } 57 | c.SetCookie(cookie.New(opts)) 58 | 59 | return c.Redirect(http.StatusTemporaryRedirect, url) 60 | } 61 | 62 | type OAuth2GoogleCallbackResponse struct { 63 | AccessToken string `json:"access_token"` 64 | ExpiresIn int64 `json:"expires_in"` 65 | RefreshToken string `json:"refresh_token"` 66 | TokenType string `json:"token_type"` 67 | } 68 | 69 | func (h *AuthHandler) oauth2GoogleCallback(c echo.Context) error { 70 | response, err := callback(c) 71 | if err != nil { 72 | log.Error().Err(err).Msg("failed callback") 73 | return err 74 | } 75 | 76 | defer response.Body.Close() 77 | b, err := io.ReadAll(response.Body) 78 | if err != nil { 79 | log.Error().Err(err).Msg("failed reading body") 80 | return err 81 | } 82 | 83 | if response.StatusCode != http.StatusOK { 84 | log.Error().Msgf("response code was: %d body: %s", response.StatusCode, b) 85 | return c.JSON(http.StatusUnauthorized, echo.HTTPError{ 86 | Code: http.StatusUnauthorized, 87 | Message: "failed to log in", 88 | }) 89 | } 90 | 91 | googleUser := &GoogleUser{} 92 | err = json.Unmarshal(b, googleUser) 93 | if err != nil { 94 | log.Error().Err(err).Msg("failed unmarshalling body") 95 | return err 96 | } 97 | 98 | ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) 99 | defer cancel() 100 | 101 | res, err := h.svc.FindOneByEmailOrUsername(ctx, googleUser.Email, "") 102 | if err != nil { 103 | var se *services.Error 104 | if errors.As(err, &se) { 105 | if se.Kind != services.NotExist { 106 | log.Error().Err(err).Msg("failed getting user") 107 | } 108 | } 109 | } 110 | 111 | var access, refresh []byte 112 | 113 | if res == nil { 114 | newUser := models.NewUser(googleUser.Email, "") 115 | access, refresh, err = newUser.Login() 116 | if err != nil { 117 | log.Error().Err(err).Msg("failed generating tokens") 118 | return err 119 | } 120 | 121 | _, err = h.svc.Create(ctx, newUser) 122 | if err != nil { 123 | log.Error().Err(err).Msg("failed inserting user") 124 | return err 125 | } 126 | } else { 127 | user := res 128 | access, refresh, err = user.Login() 129 | if err != nil { 130 | log.Error().Err(err).Msg("failed generating tokens") 131 | return err 132 | } 133 | 134 | _, err = h.svc.Update(ctx, user.Id, user) 135 | if err != nil { 136 | log.Error().Err(err).Msg("failed updating user") 137 | return err 138 | } 139 | } 140 | 141 | stateOpts := &cookie.Options{ 142 | Name: "state", 143 | Value: "", 144 | Path: "/oauth2/google/callback", 145 | SameSite: http.SameSiteLaxMode, // needs to be Lax since it's across domains 146 | HttpOnly: true, 147 | MaxAge: -1, 148 | } 149 | c.SetCookie(cookie.New(stateOpts)) 150 | cookie.SetToken(c, access, refresh) 151 | 152 | resp := &OAuth2GoogleCallbackResponse{ 153 | AccessToken: string(access), 154 | ExpiresIn: int64(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), 155 | RefreshToken: string(refresh), 156 | TokenType: "Bearer", 157 | } 158 | 159 | return c.JSON(http.StatusOK, resp) 160 | } 161 | 162 | func callback(c echo.Context) (*http.Response, error) { 163 | state := c.FormValue("state") 164 | code := c.FormValue("code") 165 | 166 | stateCooke, err := c.Cookie("state") 167 | if err != nil { 168 | return nil, fmt.Errorf("cookie was empty") 169 | } 170 | 171 | if state != stateCooke.Value { 172 | return nil, fmt.Errorf("state mismatch") 173 | } 174 | 175 | ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) 176 | defer cancel() 177 | token, err := getOAuth2GoogleConfig().Exchange(ctx, code) 178 | if err != nil { 179 | return nil, fmt.Errorf("failed exchanging autorization code: %v", err) 180 | } 181 | 182 | resp, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + token.AccessToken) 183 | if err != nil { 184 | return nil, fmt.Errorf("failed getting user info: %v", err) 185 | } 186 | 187 | return resp, nil 188 | } 189 | -------------------------------------------------------------------------------- /handlers/personal_access_token.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/alexferl/echo-openapi" 10 | "github.com/alexferl/golib/http/api/server" 11 | "github.com/labstack/echo/v4" 12 | "github.com/rs/zerolog/log" 13 | 14 | "github.com/alexferl/echo-boilerplate/models" 15 | "github.com/alexferl/echo-boilerplate/services" 16 | ) 17 | 18 | type PersonalAccessTokenService interface { 19 | Create(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) 20 | Read(ctx context.Context, userId string, id string) (*models.PersonalAccessToken, error) 21 | Find(ctx context.Context, userId string) (models.PersonalAccessTokens, error) 22 | FindOne(ctx context.Context, userId string, name string) (*models.PersonalAccessToken, error) 23 | Revoke(ctx context.Context, model *models.PersonalAccessToken) error 24 | } 25 | 26 | type PersonalAccessTokenHandler struct { 27 | *openapi.Handler 28 | svc PersonalAccessTokenService 29 | } 30 | 31 | func (h *PersonalAccessTokenHandler) Register(s *server.Server) { 32 | s.Add(http.MethodPost, "/me/personal_access_tokens", h.create) 33 | s.Add(http.MethodGet, "/me/personal_access_tokens", h.list) 34 | s.Add(http.MethodGet, "/me/personal_access_tokens/:id", h.get) 35 | s.Add(http.MethodDelete, "/me/personal_access_tokens/:id", h.revoke) 36 | } 37 | 38 | func NewPersonalAccessTokenHandler(openapi *openapi.Handler, svc PersonalAccessTokenService) *PersonalAccessTokenHandler { 39 | return &PersonalAccessTokenHandler{ 40 | Handler: openapi, 41 | svc: svc, 42 | } 43 | } 44 | 45 | type CreatePersonalAccessTokenRequest struct { 46 | Name string `json:"name"` 47 | ExpiresAt string `json:"expires_at"` 48 | } 49 | 50 | func (h *PersonalAccessTokenHandler) create(c echo.Context) error { 51 | currentUser := c.Get("user").(*models.User) 52 | 53 | body := &CreatePersonalAccessTokenRequest{} 54 | if err := c.Bind(body); err != nil { 55 | log.Error().Err(err).Msg("failed binding body") 56 | return err 57 | } 58 | 59 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 60 | defer cancel() 61 | 62 | res, err := h.svc.FindOne(ctx, currentUser.Id, body.Name) 63 | if err != nil { 64 | var se *services.Error 65 | if !errors.As(err, &se) { 66 | log.Error().Err(err).Msg("failed getting personal access token") 67 | return err 68 | } 69 | } 70 | 71 | if res != nil { 72 | return h.Validate(c, http.StatusConflict, echo.Map{"message": "token name already in-use"}) 73 | } 74 | 75 | newPAT, err := models.NewPersonalAccessToken(currentUser.Id, body.Name, body.ExpiresAt) 76 | if err != nil { 77 | if errors.Is(err, models.ErrExpiresAtPast) { 78 | m := echo.Map{ 79 | "message": "validation error", 80 | "errors": []string{models.ErrExpiresAtPast.Error()}, 81 | } 82 | return h.Validate(c, http.StatusUnprocessableEntity, m) 83 | } 84 | log.Error().Err(err).Msg("failed generating personal access token") 85 | return err 86 | } 87 | 88 | decodedToken := newPAT.Token 89 | if err = newPAT.Encrypt(); err != nil { 90 | log.Error().Err(err).Msg("failed encrypting personal access token") 91 | return err 92 | } 93 | 94 | pat, err := h.svc.Create(ctx, newPAT) 95 | if err != nil { 96 | log.Error().Err(err).Msg("failed inserting personal access token") 97 | return err 98 | } 99 | 100 | pat.Token = decodedToken 101 | 102 | return h.Validate(c, http.StatusOK, pat.CreateResponse()) 103 | } 104 | 105 | func (h *PersonalAccessTokenHandler) list(c echo.Context) error { 106 | currentUser := c.Get("user").(*models.User) 107 | 108 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 109 | defer cancel() 110 | 111 | pats, err := h.svc.Find(ctx, currentUser.Id) 112 | if err != nil { 113 | log.Error().Err(err).Msg("failed getting personal access token") 114 | return err 115 | } 116 | 117 | return h.Validate(c, http.StatusOK, pats.Response()) 118 | } 119 | 120 | func (h *PersonalAccessTokenHandler) get(c echo.Context) error { 121 | id := c.Param("id") 122 | currentUser := c.Get("user").(*models.User) 123 | 124 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 125 | defer cancel() 126 | 127 | pat, err := h.svc.Read(ctx, currentUser.Id, id) 128 | if err != nil { 129 | var se *services.Error 130 | if errors.As(err, &se) { 131 | if se.Kind == services.NotExist { 132 | return h.Validate(c, http.StatusNotFound, echo.Map{"message": se.Message}) 133 | } 134 | } 135 | log.Error().Err(err).Msg("failed getting personal access token") 136 | return err 137 | } 138 | 139 | return h.Validate(c, http.StatusOK, pat.Response()) 140 | } 141 | 142 | func (h *PersonalAccessTokenHandler) revoke(c echo.Context) error { 143 | id := c.Param("id") 144 | currentUser := c.Get("user").(*models.User) 145 | 146 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 147 | defer cancel() 148 | 149 | pat, err := h.svc.Read(ctx, currentUser.Id, id) 150 | if err != nil { 151 | var se *services.Error 152 | if errors.As(err, &se) { 153 | if se.Kind == services.NotExist { 154 | return h.Validate(c, http.StatusNotFound, echo.Map{"message": se.Message}) 155 | } 156 | } 157 | log.Error().Err(err).Msg("failed getting personal access token") 158 | return err 159 | } 160 | 161 | if pat.IsRevoked == true { 162 | return h.Validate(c, http.StatusConflict, echo.Map{"message": "personal access token already revoked"}) 163 | } 164 | 165 | err = h.svc.Revoke(ctx, pat) 166 | if err != nil { 167 | log.Error().Err(err).Msg("failed deleting personal access token") 168 | return err 169 | } 170 | 171 | return h.Validate(c, http.StatusNoContent, nil) 172 | } 173 | -------------------------------------------------------------------------------- /handlers/root.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/alexferl/echo-openapi" 8 | "github.com/alexferl/golib/http/api/server" 9 | "github.com/labstack/echo/v4" 10 | "github.com/spf13/viper" 11 | 12 | "github.com/alexferl/echo-boilerplate/config" 13 | ) 14 | 15 | type RootHandler struct { 16 | *openapi.Handler 17 | } 18 | 19 | func NewRootHandler(openapi *openapi.Handler) *RootHandler { 20 | return &RootHandler{ 21 | Handler: openapi, 22 | } 23 | } 24 | 25 | func (h *RootHandler) Register(s *server.Server) { 26 | s.Add(http.MethodGet, "/", h.Root) 27 | } 28 | 29 | // Root returns the welcome message. 30 | func (h *RootHandler) Root(c echo.Context) error { 31 | m := fmt.Sprintf("Welcome to %s", viper.GetString(config.AppName)) 32 | return c.JSON(http.StatusOK, echo.Map{"message": m}) 33 | } 34 | -------------------------------------------------------------------------------- /handlers/root_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/alexferl/echo-openapi" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/alexferl/echo-boilerplate/handlers" 12 | _ "github.com/alexferl/echo-boilerplate/testing" 13 | ) 14 | 15 | func TestHandler_Root(t *testing.T) { 16 | h := handlers.NewRootHandler(openapi.NewHandler()) 17 | userSvc := handlers.NewMockUserService(t) 18 | patSvc := handlers.NewMockPersonalAccessTokenService(t) 19 | s := getServer(userSvc, patSvc, h) 20 | 21 | req := httptest.NewRequest(http.MethodGet, "/", nil) 22 | resp := httptest.NewRecorder() 23 | 24 | s.ServeHTTP(resp, req) 25 | 26 | assert.Equal(t, http.StatusOK, resp.Code) 27 | assert.Contains(t, resp.Body.String(), "Welcome") 28 | } 29 | -------------------------------------------------------------------------------- /handlers/setup_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | api "github.com/alexferl/golib/http/api/server" 5 | 6 | "github.com/alexferl/echo-boilerplate/handlers" 7 | "github.com/alexferl/echo-boilerplate/models" 8 | "github.com/alexferl/echo-boilerplate/server" 9 | _ "github.com/alexferl/echo-boilerplate/testing" 10 | ) 11 | 12 | func getUser() *models.User { 13 | user := models.NewUser("test@example.com", "test") 14 | user.Id = "1000" 15 | user.Create(user.Id) 16 | return user 17 | } 18 | 19 | func getAdmin() *models.User { 20 | admin := models.NewUserWithRole("admin@example.com", "admin", models.AdminRole) 21 | admin.Id = "2000" 22 | admin.Create(admin.Id) 23 | return admin 24 | } 25 | 26 | func getSuper() *models.User { 27 | super := models.NewUserWithRole("super@example.com", "super", models.SuperRole) 28 | super.Id = "3000" 29 | super.Create(super.Id) 30 | return super 31 | } 32 | 33 | func getServer(userSvc handlers.UserService, patSvc handlers.PersonalAccessTokenService, handler ...handlers.Handler) *api.Server { 34 | return server.NewTestServer(userSvc, patSvc, handler...) 35 | } 36 | -------------------------------------------------------------------------------- /handlers/task.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/alexferl/echo-openapi" 10 | "github.com/alexferl/golib/http/api/server" 11 | "github.com/labstack/echo/v4" 12 | "github.com/rs/zerolog/log" 13 | 14 | "github.com/alexferl/echo-boilerplate/models" 15 | "github.com/alexferl/echo-boilerplate/services" 16 | "github.com/alexferl/echo-boilerplate/util/pagination" 17 | ) 18 | 19 | type TaskService interface { 20 | Create(ctx context.Context, id string, data *models.Task) (*models.Task, error) 21 | Read(ctx context.Context, id string) (*models.Task, error) 22 | Update(ctx context.Context, id string, data *models.Task) (*models.Task, error) 23 | Delete(ctx context.Context, id string, data *models.Task) error 24 | Find(ctx context.Context, params *models.TaskSearchParams) (int64, models.Tasks, error) 25 | } 26 | 27 | type TaskHandler struct { 28 | *openapi.Handler 29 | svc TaskService 30 | } 31 | 32 | func NewTaskHandler(openapi *openapi.Handler, svc TaskService) *TaskHandler { 33 | return &TaskHandler{ 34 | Handler: openapi, 35 | svc: svc, 36 | } 37 | } 38 | 39 | func (h *TaskHandler) Register(s *server.Server) { 40 | s.Add(http.MethodPost, "/tasks", h.create) 41 | s.Add(http.MethodGet, "/tasks", h.list) 42 | s.Add(http.MethodGet, "/tasks/:id", h.get) 43 | s.Add(http.MethodPatch, "/tasks/:id", h.update) 44 | s.Add(http.MethodPut, "/tasks/:id/transition", h.transition) 45 | s.Add(http.MethodDelete, "/tasks/:id", h.delete) 46 | } 47 | 48 | type CreateTaskRequest struct { 49 | Title string `json:"title"` 50 | } 51 | 52 | func (h *TaskHandler) create(c echo.Context) error { 53 | currentUser := c.Get("user").(*models.User) 54 | 55 | body := &CreateTaskRequest{} 56 | if err := c.Bind(body); err != nil { 57 | log.Error().Err(err).Msg("failed binding body") 58 | return err 59 | } 60 | 61 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 62 | defer cancel() 63 | 64 | model := models.NewTask() 65 | model.Title = body.Title 66 | 67 | task, err := h.svc.Create(ctx, currentUser.Id, model) 68 | if err != nil { 69 | log.Error().Err(err).Msg("failed creating task") 70 | return err 71 | } 72 | 73 | return h.Validate(c, http.StatusOK, task.Response()) 74 | } 75 | 76 | func (h *TaskHandler) list(c echo.Context) error { 77 | page, perPage, limit, skip := pagination.ParseParams(c) 78 | 79 | ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) 80 | defer cancel() 81 | 82 | params := &models.TaskSearchParams{ 83 | Completed: c.QueryParams()["completed"], 84 | CreatedBy: c.QueryParam("created_by"), 85 | Queries: c.QueryParams()["q"], 86 | Limit: limit, 87 | Skip: skip, 88 | } 89 | 90 | count, tasks, err := h.svc.Find(ctx, params) 91 | if err != nil { 92 | log.Error().Err(err).Msg("failed getting tasks") 93 | return err 94 | } 95 | 96 | pagination.SetHeaders(c.Request(), c.Response().Header(), int(count), page, perPage) 97 | 98 | return h.Validate(c, http.StatusOK, tasks.Response()) 99 | } 100 | 101 | func (h *TaskHandler) get(c echo.Context) error { 102 | id := c.Param("id") 103 | 104 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 105 | defer cancel() 106 | 107 | task, err := h.svc.Read(ctx, id) 108 | if err != nil { 109 | return h.readTask(c, err)() 110 | } 111 | 112 | return h.Validate(c, http.StatusOK, task.Response()) 113 | } 114 | 115 | type UpdateTaskRequest struct { 116 | Title *string `json:"title"` 117 | } 118 | 119 | func (h *TaskHandler) update(c echo.Context) error { 120 | id := c.Param("id") 121 | currentUser := c.Get("user").(*models.User) 122 | 123 | body := &UpdateTaskRequest{} 124 | if err := c.Bind(body); err != nil { 125 | log.Error().Err(err).Msg("failed binding body") 126 | return err 127 | } 128 | 129 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 130 | defer cancel() 131 | 132 | task, err := h.svc.Read(ctx, id) 133 | if err != nil { 134 | return h.readTask(c, err)() 135 | } 136 | 137 | if currentUser.Id != task.CreatedBy.(*models.User).Id && !currentUser.HasRoleOrHigher(models.AdminRole) { 138 | return h.Validate(c, http.StatusForbidden, echo.Map{"message": "you don't have access"}) 139 | } 140 | 141 | if body.Title != nil { 142 | task.Title = *body.Title 143 | } 144 | 145 | res, err := h.svc.Update(ctx, currentUser.Id, task) 146 | if err != nil { 147 | log.Error().Err(err).Msg("failed updating task") 148 | return err 149 | } 150 | 151 | return h.Validate(c, http.StatusOK, res.Response()) 152 | } 153 | 154 | type TransitionTaskRequest struct { 155 | Completed *bool `json:"completed"` 156 | } 157 | 158 | func (h *TaskHandler) transition(c echo.Context) error { 159 | id := c.Param("id") 160 | currentUser := c.Get("user").(*models.User) 161 | 162 | body := &TransitionTaskRequest{} 163 | if err := c.Bind(body); err != nil { 164 | log.Error().Err(err).Msg("failed binding body") 165 | return err 166 | } 167 | 168 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 169 | defer cancel() 170 | 171 | task, err := h.svc.Read(ctx, id) 172 | if err != nil { 173 | return h.readTask(c, err)() 174 | } 175 | 176 | if *body.Completed != task.Completed { 177 | if *body.Completed { 178 | task.Complete(currentUser.Id) 179 | } else { 180 | task.Incomplete() 181 | } 182 | } 183 | 184 | res, err := h.svc.Update(ctx, currentUser.Id, task) 185 | if err != nil { 186 | log.Error().Err(err).Msg("failed updating task") 187 | return err 188 | } 189 | 190 | return h.Validate(c, http.StatusOK, res.Response()) 191 | } 192 | 193 | func (h *TaskHandler) delete(c echo.Context) error { 194 | id := c.Param("id") 195 | currentUser := c.Get("user").(*models.User) 196 | 197 | ctx, cancel := context.WithTimeout(c.Request().Context(), time.Second*10) 198 | defer cancel() 199 | 200 | task, err := h.svc.Read(ctx, id) 201 | if err != nil { 202 | return h.readTask(c, err)() 203 | } 204 | 205 | if currentUser.Id != task.CreatedBy.(*models.User).Id && !currentUser.HasRoleOrHigher(models.AdminRole) { 206 | return h.Validate(c, http.StatusForbidden, echo.Map{"message": "you don't have access"}) 207 | } 208 | 209 | err = h.svc.Delete(ctx, currentUser.Id, task) 210 | if err != nil { 211 | log.Error().Err(err).Msg("failed deleting task") 212 | return err 213 | } 214 | 215 | return h.Validate(c, http.StatusNoContent, nil) 216 | } 217 | 218 | func (h *TaskHandler) readTask(c echo.Context, err error) func() error { 219 | var se *services.Error 220 | if errors.As(err, &se) { 221 | msg := echo.Map{"message": se.Message} 222 | if se.Kind == services.NotExist { 223 | return func() error { return h.Validate(c, http.StatusNotFound, msg) } 224 | } else if se.Kind == services.Deleted { 225 | return func() error { return h.Validate(c, http.StatusGone, msg) } 226 | } 227 | } 228 | log.Error().Err(err).Msg("failed getting task") 229 | return func() error { return err } 230 | } 231 | -------------------------------------------------------------------------------- /mappers/personal_access_token.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/viper" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | 11 | "github.com/alexferl/echo-boilerplate/config" 12 | "github.com/alexferl/echo-boilerplate/data" 13 | "github.com/alexferl/echo-boilerplate/models" 14 | ) 15 | 16 | // PersonalAccessToken represents the mapper used for interacting with PersonalAccessToken documents. 17 | type PersonalAccessToken struct { 18 | mapper data.Mapper 19 | } 20 | 21 | func NewPersonalAccessToken(client *mongo.Client) *PersonalAccessToken { 22 | return &PersonalAccessToken{data.NewMapper(client, viper.GetString(config.AppName), "personal_access_tokens")} 23 | } 24 | 25 | func (p PersonalAccessToken) Create(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) { 26 | filter := bson.D{{"id", model.Id}} 27 | opts := options.FindOneAndUpdate().SetUpsert(true) 28 | res, err := p.mapper.FindOneAndUpdate(ctx, filter, model, &models.PersonalAccessToken{}, opts) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return res.(*models.PersonalAccessToken), nil 34 | } 35 | 36 | func (p PersonalAccessToken) Find(ctx context.Context, filter any) (models.PersonalAccessTokens, error) { 37 | res, err := p.mapper.Find(ctx, filter, models.PersonalAccessTokens{}) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return res.(models.PersonalAccessTokens), nil 43 | } 44 | 45 | func (p PersonalAccessToken) FindOne(ctx context.Context, filter any) (*models.PersonalAccessToken, error) { 46 | res, err := p.mapper.FindOne(ctx, filter, &models.PersonalAccessToken{}) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return res.(*models.PersonalAccessToken), nil 52 | } 53 | 54 | func (p PersonalAccessToken) Update(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) { 55 | filter := bson.D{{"id", model.Id}} 56 | res, err := p.mapper.FindOneAndUpdate(ctx, filter, model, &models.PersonalAccessToken{}) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return res.(*models.PersonalAccessToken), nil 62 | } 63 | -------------------------------------------------------------------------------- /mappers/task.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/viper" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | 11 | "github.com/alexferl/echo-boilerplate/config" 12 | "github.com/alexferl/echo-boilerplate/data" 13 | "github.com/alexferl/echo-boilerplate/models" 14 | ) 15 | 16 | // Task represents the mapper used for interacting with Task documents. 17 | type Task struct { 18 | mapper data.Mapper 19 | } 20 | 21 | func NewTask(client *mongo.Client) *Task { 22 | return &Task{data.NewMapper(client, viper.GetString(config.AppName), "tasks")} 23 | } 24 | 25 | func (t *Task) Create(ctx context.Context, model *models.Task) (*models.Task, error) { 26 | seq, err := t.mapper.GetNextSequence(ctx, "tasks") 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | model.Id = seq.String() 32 | 33 | insert, err := t.mapper.InsertOne(ctx, model) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | pipeline := t.getPipeline(bson.D{{"_id", insert.InsertedID.(primitive.ObjectID)}}, 1, 0) 39 | task, err := t.getTask(ctx, pipeline) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return task, nil 45 | } 46 | 47 | func (t *Task) Find(ctx context.Context, filter any, limit int, skip int) (int64, models.Tasks, error) { 48 | count, err := t.mapper.Count(ctx, filter) 49 | if err != nil { 50 | return 0, nil, err 51 | } 52 | 53 | pipeline := t.getPipeline(filter, limit, skip) 54 | res, err := t.mapper.Aggregate(ctx, pipeline, models.Tasks{}) 55 | if err != nil { 56 | return 0, nil, err 57 | } 58 | 59 | return count, res.(models.Tasks), nil 60 | } 61 | 62 | func (t *Task) FindOneById(ctx context.Context, id string) (*models.Task, error) { 63 | pipeline := t.getPipeline(bson.D{{"id", id}}, 1, 0) 64 | res, err := t.getTask(ctx, pipeline) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return res, nil 70 | } 71 | 72 | func (t *Task) Update(ctx context.Context, model *models.Task) (*models.Task, error) { 73 | filter := bson.D{{"id", model.Id}} 74 | _, err := t.mapper.UpdateOne(ctx, filter, bson.D{{"$set", model}}) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | pipeline := t.getPipeline(filter, 1, 0) 80 | task, err := t.getTask(ctx, pipeline) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return task, nil 86 | } 87 | 88 | func (t *Task) getTask(ctx context.Context, pipeline mongo.Pipeline) (*models.Task, error) { 89 | res, err := t.mapper.Aggregate(ctx, pipeline, models.Tasks{}) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | task := res.(models.Tasks) 95 | if len(task) < 1 { 96 | return nil, data.ErrNoDocuments 97 | } 98 | 99 | return &task[0], nil 100 | } 101 | 102 | func (t *Task) getPipeline(filter any, limit int, skip int) mongo.Pipeline { 103 | if filter == nil { 104 | filter = bson.D{} 105 | } 106 | 107 | return mongo.Pipeline{ 108 | {{"$match", filter}}, 109 | {{"$lookup", bson.M{ 110 | "from": "users", 111 | "localField": "created_by.id", 112 | "foreignField": "id", 113 | "as": "created_by", 114 | }}}, 115 | {{"$unwind", "$created_by"}}, 116 | {{"$lookup", bson.M{ 117 | "from": "users", 118 | "localField": "updated_by.id", 119 | "foreignField": "id", 120 | "as": "updated_by", 121 | }}}, 122 | {{ 123 | "$unwind", bson.D{ 124 | {"path", "$updated_by"}, 125 | {"preserveNullAndEmptyArrays", true}, 126 | }, 127 | }}, 128 | {{"$lookup", bson.M{ 129 | "from": "users", 130 | "localField": "completed_by.id", 131 | "foreignField": "id", 132 | "as": "completed_by", 133 | }}}, 134 | {{ 135 | "$unwind", bson.D{ 136 | {"path", "$completed_by"}, 137 | {"preserveNullAndEmptyArrays", true}, 138 | }, 139 | }}, 140 | {{"$sort", bson.D{{"_id", -1}}}}, 141 | {{"$limit", skip + limit}}, 142 | {{"$skip", skip}}, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /mappers/user.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/viper" 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | 11 | "github.com/alexferl/echo-boilerplate/config" 12 | "github.com/alexferl/echo-boilerplate/data" 13 | "github.com/alexferl/echo-boilerplate/models" 14 | ) 15 | 16 | // User represents the mapper used for interacting with User documents. 17 | type User struct { 18 | mapper data.Mapper 19 | } 20 | 21 | func NewUser(client *mongo.Client) *User { 22 | return &User{data.NewMapper(client, viper.GetString(config.AppName), "users")} 23 | } 24 | 25 | func (u *User) Create(ctx context.Context, model *models.User) (*models.User, error) { 26 | filter := bson.D{{"id", model.Id}} 27 | opts := options.FindOneAndUpdate().SetUpsert(true) 28 | res, err := u.mapper.FindOneAndUpdate(ctx, filter, model, &models.User{}, opts) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return res.(*models.User), nil 34 | } 35 | 36 | func (u *User) Find(ctx context.Context, filter any, limit int, skip int) (int64, models.Users, error) { 37 | count, err := u.mapper.Count(ctx, filter) 38 | if err != nil { 39 | return 0, nil, err 40 | } 41 | 42 | opts := options.Find().SetLimit(int64(limit)).SetSkip(int64(skip)) 43 | res, err := u.mapper.Find(ctx, filter, models.Users{}, opts) 44 | if err != nil { 45 | return 0, nil, err 46 | } 47 | 48 | return count, res.(models.Users), nil 49 | } 50 | 51 | func (u *User) FindOne(ctx context.Context, filter any) (*models.User, error) { 52 | res, err := u.mapper.FindOne(ctx, filter, &models.User{}) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return res.(*models.User), nil 58 | } 59 | 60 | func (u *User) Update(ctx context.Context, model *models.User) (*models.User, error) { 61 | filter := bson.D{{"id", model.Id}} 62 | res, err := u.mapper.FindOneAndUpdate(ctx, filter, model, &models.User{}) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return res.(*models.User), nil 68 | } 69 | -------------------------------------------------------------------------------- /models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "fmt" 4 | 5 | // Error represents a model error 6 | type Error struct { 7 | Kind Kind 8 | Message string 9 | } 10 | 11 | // Kind defines supported error types. 12 | type Kind uint8 13 | 14 | const ( 15 | Other Kind = iota + 1 // Unclassified error. 16 | Conflict 17 | Permission 18 | ) 19 | 20 | func (k Kind) String() string { 21 | return [...]string{"other", "conflict", "permission"}[k-1] 22 | } 23 | 24 | // NewError instantiates a new error. 25 | func NewError(err error, kind Kind) error { 26 | e := &Error{ 27 | Kind: kind, 28 | Message: err.Error(), 29 | } 30 | return e 31 | } 32 | 33 | // Error returns the message. 34 | func (e *Error) Error() string { 35 | return fmt.Sprintf("kind=%s, message=%v", e.Kind, e.Message) 36 | } 37 | -------------------------------------------------------------------------------- /models/model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/xid" 7 | ) 8 | 9 | // Model is the base model for all models. 10 | // NewModel should be used unless you know what you're doing. 11 | type Model struct { 12 | Id string `bson:"id"` 13 | CreatedAt *time.Time `bson:"created_at"` 14 | CreatedBy any `bson:"created_by"` 15 | DeletedAt *time.Time `bson:"deleted_at"` 16 | DeletedBy any `bson:"deleted_by"` 17 | UpdatedAt *time.Time `bson:"updated_at"` 18 | UpdatedBy any `bson:"updated_by"` 19 | } 20 | 21 | // Ref is a reference to another document. 22 | type Ref struct { 23 | Id string `json:"id" bson:"id"` 24 | } 25 | 26 | func NewModel() *Model { 27 | return &Model{Id: xid.New().String()} 28 | } 29 | 30 | func (m *Model) Create(id string) { 31 | t := time.Now() 32 | m.CreatedAt = &t 33 | m.CreatedBy = &Ref{Id: id} 34 | } 35 | 36 | func (m *Model) Delete(id string) { 37 | t := time.Now() 38 | m.DeletedAt = &t 39 | m.DeletedBy = &Ref{Id: id} 40 | } 41 | 42 | func (m *Model) Update(id string) { 43 | t := time.Now() 44 | m.UpdatedAt = &t 45 | m.UpdatedBy = &Ref{Id: id} 46 | } 47 | -------------------------------------------------------------------------------- /models/model_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestModel(t *testing.T) { 10 | m := NewModel() 11 | assert.NotEqual(t, "", m.Id) 12 | 13 | id := "1" 14 | m.Create(id) 15 | assert.Equal(t, id, m.CreatedBy.(*Ref).Id) 16 | assert.NotNil(t, m.CreatedAt) 17 | 18 | m.Update(id) 19 | assert.Equal(t, id, m.UpdatedBy.(*Ref).Id) 20 | assert.NotNil(t, m.UpdatedAt) 21 | 22 | m.Delete(id) 23 | assert.Equal(t, id, m.DeletedBy.(*Ref).Id) 24 | assert.NotNil(t, m.DeletedAt) 25 | } 26 | -------------------------------------------------------------------------------- /models/personal_access_token.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/rs/xid" 8 | 9 | "github.com/alexferl/echo-boilerplate/util/jwt" 10 | "github.com/alexferl/echo-boilerplate/util/password" 11 | ) 12 | 13 | var ErrExpiresAtPast = errors.New("expires_at cannot be in the past") 14 | 15 | type PersonalAccessToken struct { 16 | Id string `bson:"id"` 17 | CreatedAt *time.Time `bson:"created_at"` 18 | ExpiresAt *time.Time `bson:"expires_at"` 19 | IsRevoked bool `bson:"is_revoked"` 20 | Name string `bson:"name"` 21 | Token string `bson:"token"` 22 | UserId string `bson:"user_id"` 23 | } 24 | 25 | type PersonalAccessTokenResponse struct { 26 | Id string `json:"id" bson:"id"` 27 | CreatedAt *time.Time `json:"created_at"` 28 | ExpiresAt *time.Time `json:"expires_at"` 29 | IsRevoked bool `json:"is_revoked"` 30 | Name string `json:"name"` 31 | UserId string `json:"user_id"` 32 | } 33 | 34 | type PersonalAccessTokenCreateResponse struct { 35 | PersonalAccessTokenResponse 36 | Token string `json:"token"` 37 | } 38 | 39 | func NewPersonalAccessToken(userId string, name string, expiresAt string) (*PersonalAccessToken, error) { 40 | t, err := time.Parse("2006-01-02", expiresAt) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | now := time.Now() 46 | if t.Before(now) { 47 | return nil, ErrExpiresAtPast 48 | } 49 | 50 | pat, err := jwt.GeneratePersonalToken(userId, t.Sub(now), nil) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &PersonalAccessToken{ 56 | Id: xid.New().String(), 57 | CreatedAt: &now, 58 | ExpiresAt: &t, 59 | Name: name, 60 | Token: string(pat), 61 | UserId: userId, 62 | }, nil 63 | } 64 | 65 | func (pat *PersonalAccessToken) Response() *PersonalAccessTokenResponse { 66 | return &PersonalAccessTokenResponse{ 67 | Id: pat.Id, 68 | CreatedAt: pat.CreatedAt, 69 | ExpiresAt: pat.ExpiresAt, 70 | IsRevoked: pat.IsRevoked, 71 | Name: pat.Name, 72 | UserId: pat.UserId, 73 | } 74 | } 75 | 76 | func (pat *PersonalAccessToken) CreateResponse() *PersonalAccessTokenCreateResponse { 77 | return &PersonalAccessTokenCreateResponse{ 78 | PersonalAccessTokenResponse: PersonalAccessTokenResponse{ 79 | Id: pat.Id, 80 | CreatedAt: pat.CreatedAt, 81 | ExpiresAt: pat.ExpiresAt, 82 | IsRevoked: pat.IsRevoked, 83 | Name: pat.Name, 84 | UserId: pat.UserId, 85 | }, 86 | Token: pat.Token, 87 | } 88 | } 89 | 90 | func (pat *PersonalAccessToken) Encrypt() error { 91 | b, err := password.Hash([]byte(pat.Token)) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | pat.Token = b 97 | 98 | return nil 99 | } 100 | 101 | func (pat *PersonalAccessToken) Validate(s string) error { 102 | return password.Verify([]byte(pat.Token), []byte(s)) 103 | } 104 | 105 | type PersonalAccessTokens []PersonalAccessToken 106 | 107 | type PersonalAccessTokensResponse struct { 108 | Tokens []PersonalAccessTokenResponse `json:"personal_access_tokens"` 109 | } 110 | 111 | func (pats PersonalAccessTokens) Response() *PersonalAccessTokensResponse { 112 | res := make([]PersonalAccessTokenResponse, 0) 113 | for _, pat := range pats { 114 | res = append(res, *pat.Response()) 115 | } 116 | return &PersonalAccessTokensResponse{Tokens: res} 117 | } 118 | -------------------------------------------------------------------------------- /models/personal_access_token_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPersonalAccessToken(t *testing.T) { 11 | user := NewUser("test@email.com", "test") 12 | 13 | // expires_at not in the past 14 | _, err := NewPersonalAccessToken(user.Id, "My Token", time.Now().Format("2006-01-02")) 15 | assert.Error(t, err) 16 | assert.Equal(t, ErrExpiresAtPast, err) 17 | 18 | pat, err := NewPersonalAccessToken(user.Id, "My Token", time.Now().Add((7*24)*time.Hour).Format("2006-01-02")) 19 | assert.NoError(t, err) 20 | 21 | token := pat.Token 22 | create := pat.CreateResponse() 23 | assert.Equal(t, token, create.Token) 24 | 25 | resp := pat.Response() 26 | assert.Equal(t, pat.ExpiresAt, resp.ExpiresAt) 27 | 28 | err = pat.Encrypt() 29 | assert.NoError(t, err) 30 | 31 | err = pat.Validate(token) 32 | assert.NoError(t, err) 33 | } 34 | 35 | func TestPersonalAccessTokens(t *testing.T) { 36 | user := NewUser("test@email.com", "test") 37 | 38 | pat1, err := NewPersonalAccessToken(user.Id, "My Token1", time.Now().Add((7*24)*time.Hour).Format("2006-01-02")) 39 | assert.NoError(t, err) 40 | pat2, err := NewPersonalAccessToken(user.Id, "My Token1", time.Now().Add((7*24)*time.Hour).Format("2006-01-02")) 41 | assert.NoError(t, err) 42 | 43 | pats := PersonalAccessTokens{*pat1, *pat2} 44 | resp := pats.Response() 45 | assert.Len(t, resp.Tokens, 2) 46 | } 47 | -------------------------------------------------------------------------------- /models/setup_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import _ "github.com/alexferl/echo-boilerplate/testing" 4 | -------------------------------------------------------------------------------- /models/task.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson" 7 | "go.mongodb.org/mongo-driver/bson/primitive" 8 | 9 | utilBSON "github.com/alexferl/echo-boilerplate/util/bson" 10 | ) 11 | 12 | type Task struct { 13 | *Model `bson:",inline"` 14 | Completed bool `bson:"completed"` 15 | CompletedAt *time.Time `bson:"completed_at"` 16 | CompletedBy any `bson:"completed_by"` 17 | Title string `bson:"title"` 18 | } 19 | 20 | type TaskResponse struct { 21 | Id string `json:"id"` 22 | Completed bool `json:"completed"` 23 | CompletedAt *time.Time `json:"completed_at"` 24 | CompletedBy *UserRef `json:"completed_by"` 25 | CreatedAt *time.Time `json:"created_at"` 26 | CreatedBy *UserRef `json:"created_by"` 27 | DeletedAt *time.Time `json:"-"` 28 | DeletedBy *UserRef `json:"-"` 29 | Title string `json:"title"` 30 | UpdatedAt *time.Time `json:"updated_at"` 31 | UpdatedBy *UserRef `json:"updated_by"` 32 | } 33 | 34 | func NewTask() *Task { 35 | return &Task{Model: NewModel()} 36 | } 37 | 38 | func (t *Task) Response() *TaskResponse { 39 | resp := &TaskResponse{ 40 | Id: t.Id, 41 | Completed: t.Completed, 42 | CompletedAt: t.CompletedAt, 43 | CreatedAt: t.CreatedAt, 44 | CreatedBy: t.CreatedBy.(*User).Ref(), 45 | Title: t.Title, 46 | UpdatedAt: t.UpdatedAt, 47 | } 48 | 49 | if t.CompletedBy != nil { 50 | resp.CompletedBy = t.CompletedBy.(*User).Ref() 51 | } 52 | 53 | if t.UpdatedBy != nil { 54 | resp.UpdatedBy = t.UpdatedBy.(*User).Ref() 55 | } 56 | 57 | return resp 58 | } 59 | 60 | func (t *Task) Complete(id string) { 61 | t.Completed = true 62 | now := time.Now() 63 | t.CompletedAt = &now 64 | t.CompletedBy = &Ref{Id: id} 65 | } 66 | 67 | func (t *Task) Incomplete() { 68 | t.Completed = false 69 | t.CompletedAt = nil 70 | t.CompletedBy = nil 71 | } 72 | 73 | func (t *Task) MarshalBSON() ([]byte, error) { 74 | type Alias Task 75 | aux := &struct { 76 | *Alias `bson:",inline"` 77 | }{ 78 | Alias: (*Alias)(t), 79 | } 80 | 81 | if t.CompletedBy != nil { 82 | user, ok := t.CompletedBy.(*User) 83 | if ok { 84 | aux.CompletedBy = &Ref{Id: user.Id} 85 | } 86 | } 87 | 88 | if t.CreatedBy != nil { 89 | user, ok := t.CreatedBy.(*User) 90 | if ok { 91 | aux.CreatedBy = &Ref{Id: user.Id} 92 | } 93 | } 94 | 95 | if t.DeletedBy != nil { 96 | user, ok := t.DeletedBy.(*User) 97 | if ok { 98 | aux.DeletedBy = &Ref{Id: user.Id} 99 | } 100 | } 101 | 102 | if t.UpdatedBy != nil { 103 | user, ok := t.UpdatedBy.(*User) 104 | if ok { 105 | aux.UpdatedBy = &Ref{Id: user.Id} 106 | } 107 | } 108 | 109 | return bson.Marshal(aux) 110 | } 111 | 112 | func (t *Task) UnmarshalBSON(data []byte) error { 113 | type Alias Task 114 | aux := &struct { 115 | *Alias `bson:",inline"` 116 | }{ 117 | Alias: (*Alias)(t), 118 | } 119 | 120 | if err := bson.Unmarshal(data, aux); err != nil { 121 | return err 122 | } 123 | 124 | if t.CompletedBy != nil { 125 | var u *User 126 | err := utilBSON.DocToStruct(aux.CompletedBy.(primitive.D), &u) 127 | if err != nil { 128 | return err 129 | } 130 | t.CompletedBy = u 131 | } 132 | 133 | if t.CreatedBy != nil { 134 | var u *User 135 | err := utilBSON.DocToStruct(aux.CreatedBy.(primitive.D), &u) 136 | if err != nil { 137 | return err 138 | } 139 | t.CreatedBy = u 140 | } 141 | 142 | if t.DeletedBy != nil { 143 | var u *User 144 | err := utilBSON.DocToStruct(aux.DeletedBy.(primitive.D), &u) 145 | if err != nil { 146 | return err 147 | } 148 | t.DeletedBy = u 149 | } 150 | 151 | if t.UpdatedBy != nil { 152 | var u *User 153 | err := utilBSON.DocToStruct(aux.UpdatedBy.(primitive.D), &u) 154 | if err != nil { 155 | return err 156 | } 157 | t.UpdatedBy = u 158 | } 159 | 160 | return nil 161 | } 162 | 163 | type Tasks []Task 164 | 165 | type TasksResponse struct { 166 | Tasks []TaskResponse `json:"tasks"` 167 | } 168 | 169 | func (t Tasks) Response() *TasksResponse { 170 | res := make([]TaskResponse, 0) 171 | for _, task := range t { 172 | res = append(res, *task.Response()) 173 | } 174 | return &TasksResponse{Tasks: res} 175 | } 176 | 177 | type TaskSearchParams struct { 178 | Completed []string 179 | CreatedBy string 180 | Queries []string 181 | Limit int 182 | Skip int 183 | } 184 | -------------------------------------------------------------------------------- /models/task_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "go.mongodb.org/mongo-driver/bson" 8 | ) 9 | 10 | func TestTask(t *testing.T) { 11 | task := NewTask() 12 | assert.NotEqual(t, "", task.Id) 13 | 14 | id := "1" 15 | task.Create(id) 16 | assert.Equal(t, id, task.CreatedBy.(*Ref).Id) 17 | assert.NotNil(t, task.CreatedAt) 18 | 19 | task.Update(id) 20 | assert.Equal(t, id, task.UpdatedBy.(*Ref).Id) 21 | assert.NotNil(t, task.UpdatedAt) 22 | 23 | task.Delete(id) 24 | assert.Equal(t, id, task.DeletedBy.(*Ref).Id) 25 | assert.NotNil(t, task.DeletedAt) 26 | 27 | task.Complete(id) 28 | assert.Equal(t, id, task.CompletedBy.(*Ref).Id) 29 | assert.NotNil(t, task.CompletedAt) 30 | 31 | task.Incomplete() 32 | assert.Nil(t, task.CompletedBy) 33 | assert.Nil(t, task.CompletedAt) 34 | } 35 | 36 | func TestTask_CustomBSON(t *testing.T) { 37 | task := NewTask() 38 | id := "1" 39 | user := NewUser("test@example.com", "test") 40 | user.Id = id 41 | task.CompletedBy = user 42 | task.CreatedBy = user 43 | task.DeletedBy = user 44 | task.UpdatedBy = user 45 | 46 | b, _ := bson.Marshal(task) 47 | 48 | var m Task 49 | _ = bson.Unmarshal(b, &m) 50 | 51 | assert.Equal(t, id, m.CompletedBy.(*User).Id) 52 | assert.Equal(t, id, m.CreatedBy.(*User).Id) 53 | assert.Equal(t, id, m.DeletedBy.(*User).Id) 54 | assert.Equal(t, id, m.UpdatedBy.(*User).Id) 55 | assert.IsType(t, &UserRef{}, m.Response().CompletedBy) 56 | assert.IsType(t, &UserRef{}, m.Response().CreatedBy) 57 | assert.IsType(t, &UserRef{}, m.Response().UpdatedBy) 58 | } 59 | 60 | func TestTasks(t *testing.T) { 61 | user := NewUser("test@example.com", "test") 62 | 63 | task1 := NewTask() 64 | task1.Create("1") 65 | task1.CreatedBy = user 66 | 67 | task2 := NewTask() 68 | task2.Create("1") 69 | task2.CreatedBy = user 70 | 71 | tasks := Tasks{*task1, *task2} 72 | resp := tasks.Response() 73 | 74 | assert.Len(t, resp.Tasks, 2) 75 | } 76 | -------------------------------------------------------------------------------- /openapi/components/headers/Link.yaml: -------------------------------------------------------------------------------- 1 | type: string 2 | example: ; rel=next, ; rel=last, ; rel=first, ; rel=prev 3 | -------------------------------------------------------------------------------- /openapi/components/headers/SetCookie.yaml: -------------------------------------------------------------------------------- 1 | type: string 2 | example: access_token=eyJhbGciOi...; Path=/; Secure; SameSite=Strict; Domain=localhost 3 | -------------------------------------------------------------------------------- /openapi/components/headers/SetCookieRefresh.yaml: -------------------------------------------------------------------------------- 1 | type: string 2 | example: refresh_token=eyJhbGciOi...; Path=/auth; HttpOnly; Secure; SameSite=Strict; Domain=localhost 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Next-Page.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 6 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Page.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 5 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Per-Page.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 10 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Prev-Page.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 4 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Total-Pages.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 10 3 | -------------------------------------------------------------------------------- /openapi/components/headers/X-Total.yaml: -------------------------------------------------------------------------------- 1 | type: integer 2 | example: 100 3 | -------------------------------------------------------------------------------- /openapi/components/responses/BadRequest.yaml: -------------------------------------------------------------------------------- 1 | description: The request could not be understood by the server due to malformed syntax. 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/Conflict.yaml: -------------------------------------------------------------------------------- 1 | description: The request could not be completed due to a conflict with the current state of the target resource 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/Forbidden.yaml: -------------------------------------------------------------------------------- 1 | description: The server understood the request but refuses to authorize it 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/Gone.yaml: -------------------------------------------------------------------------------- 1 | description: The target resource is no longer available 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/NotFound.yaml: -------------------------------------------------------------------------------- 1 | description: The requested resource was not found 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/Unauthorized.yaml: -------------------------------------------------------------------------------- 1 | description: The request has not been applied because it lacks valid authentication credentials for the target resource 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/Unexpected.yaml: -------------------------------------------------------------------------------- 1 | description: There was an unexpected error 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/Error.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/responses/UnprocessableEntity.yaml: -------------------------------------------------------------------------------- 1 | description: The request was well-formed but was unable to be followed due to semantic errors 2 | content: 3 | application/json: 4 | schema: 5 | $ref: '../schemas/ValidationError.yaml' 6 | -------------------------------------------------------------------------------- /openapi/components/schemas/Error.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Error response 3 | additionalProperties: false 4 | required: 5 | - message 6 | properties: 7 | message: 8 | type: string 9 | -------------------------------------------------------------------------------- /openapi/components/schemas/ValidationError.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - message 5 | - errors 6 | properties: 7 | message: 8 | type: string 9 | errors: 10 | type: array 11 | items: 12 | type: string 13 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/Login.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Auth login request 3 | additionalProperties: false 4 | required: 5 | - password 6 | properties: 7 | email: 8 | type: string 9 | description: The email of the user 10 | example: test@example.com 11 | password: 12 | type: string 13 | description: The password of the user 14 | example: correct-horse-staple-battery 15 | username: 16 | type: string 17 | description: Username of the user 18 | example: test 19 | oneOf: 20 | - required: 21 | - email 22 | - required: 23 | - username 24 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/Logout.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Auth logout request 3 | additionalProperties: false 4 | required: 5 | - refresh_token 6 | properties: 7 | refresh_token: 8 | type: string 9 | description: Refresh token 10 | example: eyJhbGciOi... 11 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/RefreshToken.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Auth refresh token request 3 | additionalProperties: false 4 | required: 5 | - grant_type 6 | - refresh_token 7 | properties: 8 | grant_type: 9 | type: string 10 | description: Set it to `refresh_token` 11 | example: refresh_token 12 | enum: ['refresh_token'] 13 | refresh_token: 14 | type: string 15 | description: Refresh token 16 | example: eyJhbGciOi... 17 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/Signup.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Sign Up request 3 | additionalProperties: false 4 | required: 5 | - email 6 | - username 7 | - password 8 | properties: 9 | email: 10 | type: string 11 | format: email 12 | description: The email of the user 13 | example: test@example.com 14 | username: 15 | type: string 16 | pattern: '^[a-zA-Z0-9]+(?:[-._][a-zA-Z0-9]+)*$' 17 | description: The username of the user 18 | minLength: 2 19 | maxLength: 30 20 | example: test 21 | name: 22 | type: string 23 | description: The name of the user 24 | example: Test 25 | minLength: 1 26 | maxLength: 100 27 | bio: 28 | type: string 29 | description: The biography of the user 30 | example: This is my bio. 31 | minLength: 0 32 | maxLength: 1000 33 | password: 34 | type: string 35 | format: password 36 | description: The password of the user 37 | example: correct-horse-staple-battery 38 | minLength: 12 39 | maxLength: 100 40 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/Token.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Token response 3 | additionalProperties: false 4 | required: 5 | - exp 6 | - iat 7 | - iss 8 | - nbf 9 | - sub 10 | - type 11 | properties: 12 | exp: 13 | type: string 14 | description: Token expiration date 15 | example: '2024-01-23T19:45:48Z' 16 | iat: 17 | type: string 18 | description: Token issued date 19 | example: '2024-01-23T19:35:48Z' 20 | iss: 21 | type: string 22 | description: Token issuer 23 | example: 'http://localhost:1323' 24 | nbf: 25 | type: string 26 | description: Token not before date 27 | example: '2024-01-23T19:35:48Z' 28 | sub: 29 | type: string 30 | description: Token subject 31 | example: cmfh5i4i016kiqso7gr0 32 | type: 33 | type: string 34 | description: Token kind 35 | example: access 36 | -------------------------------------------------------------------------------- /openapi/components/schemas/auth/TokenResponse.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Token response 3 | additionalProperties: false 4 | required: 5 | - access_token 6 | - expires_in 7 | - refresh_token 8 | - token_type 9 | properties: 10 | access_token: 11 | type: string 12 | description: Access token 13 | example: eyJhbGciOi... 14 | expires_in: 15 | type: number 16 | description: access_token expiry in seconds 17 | example: 3600 18 | refresh_token: 19 | type: string 20 | description: Refresh token 21 | example: eyJhbGciOi... 22 | token_type: 23 | type: string 24 | description: Type of token 25 | enum: 26 | - Bearer 27 | -------------------------------------------------------------------------------- /openapi/components/schemas/personal_access_tokens/Create.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - name 5 | - expires_at 6 | properties: 7 | name: 8 | type: string 9 | description: Name of the token 10 | minLength: 1 11 | maxLength: 100 12 | example: my_token 13 | expires_at: 14 | type: string 15 | format: date 16 | description: Token expiration date time 17 | example: '2038-01-19' 18 | -------------------------------------------------------------------------------- /openapi/components/schemas/personal_access_tokens/Create_response.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - token 5 | properties: 6 | token: 7 | type: string 8 | description: The token 9 | example: eyJhbGciOi... 10 | -------------------------------------------------------------------------------- /openapi/components/schemas/personal_access_tokens/List.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - personal_access_tokens 5 | properties: 6 | personal_access_tokens: 7 | type: array 8 | items: 9 | $ref: './Token.yaml' 10 | -------------------------------------------------------------------------------- /openapi/components/schemas/personal_access_tokens/Token.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - id 5 | - created_at 6 | - expires_at 7 | - is_revoked 8 | - name 9 | - user_id 10 | properties: 11 | id: 12 | type: string 13 | description: Unique identifier for this object 14 | example: cdndmc5fcls6kndagdgg 15 | created_at: 16 | type: string 17 | format: date-time 18 | description: Token creation date time 19 | example: '2022-11-13T17:28:41.465Z' 20 | expires_at: 21 | type: string 22 | format: date-time 23 | description: Token expiration date time 24 | example: '2022-12-13T00:00:00.00Z' 25 | is_revoked: 26 | type: boolean 27 | description: True if the token is revoked 28 | example: false 29 | name: 30 | type: string 31 | description: The name of the token 32 | example: My Token 33 | user_id: 34 | type: string 35 | description: Unique identifier for this object 36 | example: cdmt48tfcls65a7mb590 37 | -------------------------------------------------------------------------------- /openapi/components/schemas/tasks/Create.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Task create request 3 | additionalProperties: false 4 | required: 5 | - title 6 | properties: 7 | title: 8 | type: string 9 | description: The title of the task 10 | minLength: 1 11 | maxLength: 100 12 | example: My Task 13 | -------------------------------------------------------------------------------- /openapi/components/schemas/tasks/List.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - tasks 5 | properties: 6 | tasks: 7 | type: array 8 | items: 9 | $ref: './Task.yaml' 10 | -------------------------------------------------------------------------------- /openapi/components/schemas/tasks/Task.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - id 5 | - completed 6 | - completed_at 7 | - completed_by 8 | - created_at 9 | - created_by 10 | - title 11 | - updated_at 12 | - updated_by 13 | properties: 14 | id: 15 | type: string 16 | description: Unique identifier for this object 17 | example: '1' 18 | completed: 19 | type: boolean 20 | example: true 21 | completed_at: 22 | type: string 23 | format: date-time 24 | description: Task completion date time 25 | example: '2022-11-13T07:12:33.017Z' 26 | nullable: true 27 | completed_by: 28 | type: object 29 | nullable: true 30 | allOf: 31 | - $ref: '../users/Ref.yaml' 32 | created_at: 33 | type: string 34 | format: date-time 35 | description: Task creation date time 36 | example: '2022-11-12T14:54:18.103Z' 37 | nullable: true 38 | created_by: 39 | $ref: '../users/Ref.yaml' 40 | title: 41 | type: string 42 | description: The title of the task 43 | example: My Task 44 | updated_at: 45 | type: string 46 | format: date-time 47 | description: Task update date time 48 | example: '2022-11-12T14:58:33.409Z' 49 | nullable: true 50 | updated_by: 51 | type: object 52 | nullable: true 53 | allOf: 54 | - $ref: '../users/Ref.yaml' 55 | -------------------------------------------------------------------------------- /openapi/components/schemas/tasks/Transition.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Task update request 3 | additionalProperties: false 4 | required: 5 | - completed 6 | properties: 7 | completed: 8 | type: boolean 9 | example: true 10 | -------------------------------------------------------------------------------- /openapi/components/schemas/tasks/Update.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: Task update request 3 | additionalProperties: false 4 | properties: 5 | title: 6 | type: string 7 | description: The title of the task 8 | minLength: 0 9 | maxLength: 100 10 | example: My Updated Task 11 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/List.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - users 5 | properties: 6 | users: 7 | type: array 8 | items: 9 | $ref: './User.yaml' 10 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/Ref.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - id 5 | - name 6 | - username 7 | properties: 8 | id: 9 | type: string 10 | description: Unique identifier for this object 11 | example: cdmt48tfcls65a7mb590 12 | name: 13 | type: string 14 | description: The name of the user 15 | example: Test 16 | username: 17 | type: string 18 | pattern: '^[0-9a-zA-Z._]+$' 19 | description: The username of the user 20 | example: test 21 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/Update.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: User update request 3 | additionalProperties: false 4 | properties: 5 | name: 6 | type: string 7 | description: Name of the user 8 | minLength: 0 9 | maxLength: 100 10 | example: Test Updated 11 | bio: 12 | type: string 13 | description: Biography of the user 14 | minLength: 0 15 | maxLength: 1000 16 | example: This is my updated example bio. 17 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/User.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: User response 3 | additionalProperties: false 4 | required: 5 | - id 6 | - bio 7 | - created_at 8 | - is_banned 9 | - is_locked 10 | - name 11 | - updated_at 12 | - username 13 | properties: 14 | id: 15 | type: string 16 | description: Unique identifier for this object 17 | example: cdmt48tfcls65a7mb590 18 | bio: 19 | type: string 20 | description: Biography of the user 21 | example: This is my bio. 22 | created_at: 23 | type: string 24 | format: date-time 25 | description: User creation date time 26 | example: '2022-11-12T09:11:42.420Z' 27 | nullable: true 28 | email: 29 | type: string 30 | description: Email of the user 31 | example: test@example.com 32 | is_banned: 33 | type: boolean 34 | description: True if account is banned 35 | example: false 36 | is_locked: 37 | type: boolean 38 | description: True if account is locked 39 | example: false 40 | last_login_at: 41 | type: string 42 | format: date-time 43 | description: User last login date time 44 | example: '2022-11-12T10:23:56.069Z' 45 | nullable: true 46 | last_logout_at: 47 | type: string 48 | format: date-time 49 | description: User last logout date time 50 | example: '2022-11-12T10:23:56.069Z' 51 | nullable: true 52 | last_refresh_at: 53 | type: string 54 | format: date-time 55 | description: User last token refresh date time 56 | example: '2022-11-12T10:23:56.069Z' 57 | nullable: true 58 | name: 59 | type: string 60 | description: Name of the user 61 | example: Test 62 | updated_at: 63 | type: string 64 | format: date-time 65 | description: User last update date time 66 | example: '2022-11-12T10:23:56.069Z' 67 | nullable: true 68 | username: 69 | type: string 70 | pattern: '^[0-9a-zA-Z._]+$' 71 | description: Username of the user 72 | example: test 73 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/me/CurrentUser.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | additionalProperties: false 3 | required: 4 | - id 5 | - bio 6 | - created_at 7 | - email 8 | - name 9 | - updated_at 10 | - username 11 | properties: 12 | id: 13 | type: string 14 | description: Unique identifier for this object 15 | example: cdmt48tfcls65a7mb590 16 | bio: 17 | type: string 18 | description: Biography of the user 19 | example: This is my bio. 20 | created_at: 21 | type: string 22 | format: date-time 23 | description: User creation date time 24 | example: '2022-11-12T09:11:42.420Z' 25 | nullable: true 26 | email: 27 | type: string 28 | description: Email of the user 29 | example: test@example.com 30 | name: 31 | type: string 32 | description: Name of the user 33 | example: Test 34 | updated_at: 35 | type: string 36 | format: date-time 37 | description: User last update date time 38 | example: '2022-11-12T10:23:56.069Z' 39 | nullable: true 40 | username: 41 | type: string 42 | pattern: '^[0-9a-zA-Z._]+$' 43 | description: Username of the user 44 | example: test 45 | -------------------------------------------------------------------------------- /openapi/components/schemas/users/me/Update.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | description: User update request 3 | additionalProperties: false 4 | properties: 5 | name: 6 | type: string 7 | description: The name of the user 8 | minLength: 0 9 | maxLength: 100 10 | example: Test Updated 11 | bio: 12 | type: string 13 | description: The biography of the user 14 | minLength: 0 15 | maxLength: 1000 16 | example: This is my updated example bio. 17 | -------------------------------------------------------------------------------- /openapi/components/securitySchemes/BearerAuth.yaml: -------------------------------------------------------------------------------- 1 | type: http 2 | scheme: bearer 3 | bearerFormat: JWT 4 | -------------------------------------------------------------------------------- /openapi/components/securitySchemes/CookieAuth.yaml: -------------------------------------------------------------------------------- 1 | type: apiKey 2 | in: cookie 3 | name: access_token 4 | -------------------------------------------------------------------------------- /openapi/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: echo-boilerplate 4 | description: echo-boilerplate API 5 | version: 1.0.0 6 | contact: 7 | url: https://github.com/alexferl/echo-boilerplate 8 | license: 9 | name: MIT 10 | url: https://raw.githubusercontent.com/alexferl/echo-boilerplate/master/LICENSE 11 | servers: 12 | - url: http://localhost:1323 13 | - url: http://example.com # golang httptest 14 | - url: https://{environment}.example.com 15 | variables: 16 | environment: 17 | default: api # Production server 18 | enum: 19 | - api # Production server 20 | - api.staging # Staging server 21 | - api.test # Test server 22 | tags: 23 | - name: auth 24 | description: Authentication operations 25 | - name: personal access tokens 26 | description: Operations on personal access tokens 27 | - name: tasks 28 | description: Operations on tasks 29 | - name: users 30 | description: Operations on users 31 | paths: 32 | /auth/login: 33 | $ref: './paths/auth/login.yaml' 34 | /auth/logout: 35 | $ref: './paths/auth/logout.yaml' 36 | /auth/refresh: 37 | $ref: './paths/auth/refresh.yaml' 38 | /auth/signup: 39 | $ref: './paths/auth/signup.yaml' 40 | /auth/token: 41 | $ref: './paths/auth/token.yaml' 42 | /me: 43 | $ref: './paths/users/me.yaml' 44 | /me/personal_access_tokens: 45 | $ref: './paths/personal_access_tokens/personal_access_tokens.yaml' 46 | /me/personal_access_tokens/{id}: 47 | $ref: './paths/personal_access_tokens/personal_access_tokens_{id}.yaml' 48 | /tasks: 49 | $ref: './paths/tasks/tasks.yaml' 50 | /tasks/{id}: 51 | $ref: './paths/tasks/{id}.yaml' 52 | /tasks/{id}/transition: 53 | $ref: './paths/tasks/{id}_transition.yaml' 54 | /users: 55 | $ref: './paths/users/users.yaml' 56 | /users/{username}: 57 | $ref: './paths/users/{username}.yaml' 58 | /users/{username}/ban: 59 | $ref: './paths/users/{username}_ban.yaml' 60 | /users/{username}/lock: 61 | $ref: './paths/users/{username}_lock.yaml' 62 | /users/{username}/roles/{role}: 63 | $ref: './paths/users/{username}_roles_{role}.yaml' 64 | components: 65 | securitySchemes: 66 | cookieAuth: 67 | $ref: './components/securitySchemes/CookieAuth.yaml' 68 | bearerAuth: 69 | $ref: './components/securitySchemes/BearerAuth.yaml' 70 | -------------------------------------------------------------------------------- /openapi/paths/auth/login.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Log in 3 | description: Returns tokens. 4 | operationId: login 5 | security: [] 6 | tags: 7 | - auth 8 | requestBody: 9 | required: true 10 | content: 11 | application/json: 12 | schema: 13 | $ref: '../../components/schemas/auth/Login.yaml' 14 | responses: 15 | '200': 16 | description: Successfully returned tokens 17 | content: 18 | application/json: 19 | schema: 20 | $ref: '../../components/schemas/auth/TokenResponse.yaml' 21 | headers: 22 | Set-Cookie: 23 | schema: 24 | $ref: '../../components/headers/SetCookie.yaml' 25 | "\0Set-Cookie": 26 | schema: 27 | $ref: '../../components/headers/SetCookieRefresh.yaml' 28 | '401': 29 | $ref: '../../components/responses/Unauthorized.yaml' 30 | '422': 31 | $ref: '../../components/responses/UnprocessableEntity.yaml' 32 | -------------------------------------------------------------------------------- /openapi/paths/auth/logout.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Log out 3 | description: Revoke a refresh token. 4 | operationId: authLogout 5 | security: [] 6 | tags: 7 | - auth 8 | requestBody: 9 | content: 10 | application/json: 11 | schema: 12 | $ref: '../../components/schemas/auth/Logout.yaml' 13 | responses: 14 | '204': 15 | description: Successfully revoked token 16 | headers: 17 | Set-Cookie: 18 | schema: 19 | type: string 20 | example: access_token=; Path=/; Secure; SameSite=Strict; Domain=localhost 21 | "\0Set-Cookie": 22 | schema: 23 | type: string 24 | example: refresh_token=; Path=/auth; HttpOnly; Secure; SameSite=Strict; Domain=localhost 25 | '401': 26 | $ref: '../../components/responses/Unauthorized.yaml' 27 | '422': 28 | $ref: '../../components/responses/UnprocessableEntity.yaml' 29 | -------------------------------------------------------------------------------- /openapi/paths/auth/refresh.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Refresh token 3 | description: Returns new tokens. 4 | operationId: authRefresh 5 | security: [] 6 | tags: 7 | - auth 8 | requestBody: 9 | content: 10 | application/json: 11 | schema: 12 | $ref: '../../components/schemas/auth/RefreshToken.yaml' 13 | responses: 14 | '200': 15 | description: Successfully returned tokens 16 | content: 17 | application/json: 18 | schema: 19 | $ref: '../../components/schemas/auth/TokenResponse.yaml' 20 | headers: 21 | Set-Cookie: 22 | schema: 23 | $ref: '../../components/headers/SetCookie.yaml' 24 | "\0Set-Cookie": 25 | schema: 26 | $ref: '../../components/headers/SetCookieRefresh.yaml' 27 | '401': 28 | $ref: '../../components/responses/Unauthorized.yaml' 29 | '422': 30 | $ref: '../../components/responses/UnprocessableEntity.yaml' 31 | -------------------------------------------------------------------------------- /openapi/paths/auth/signup.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Sign up 3 | description: Returns the newly created user. 4 | operationId: signup 5 | security: [] 6 | tags: 7 | - auth 8 | requestBody: 9 | required: true 10 | content: 11 | application/json: 12 | schema: 13 | $ref: '../../components/schemas/auth/Signup.yaml' 14 | responses: 15 | '200': 16 | description: Successfully created user 17 | content: 18 | application/json: 19 | schema: 20 | $ref: '../../components/schemas/users/me/CurrentUser.yaml' 21 | '409': 22 | $ref: '../../components/responses/Conflict.yaml' 23 | '422': 24 | $ref: '../../components/responses/UnprocessableEntity.yaml' 25 | -------------------------------------------------------------------------------- /openapi/paths/auth/token.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: Get token 3 | description: Returns access token. 4 | operationId: getAccessToken 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - auth 10 | responses: 11 | '200': 12 | description: Access token 13 | content: 14 | application/json: 15 | schema: 16 | $ref: '../../components/schemas/auth/Token.yaml' 17 | '401': 18 | $ref: '../../components/responses/Unauthorized.yaml' 19 | -------------------------------------------------------------------------------- /openapi/paths/personal_access_tokens/personal_access_tokens.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Create a personal access token 3 | description: Returns newly personal access token for the authenticated user. 4 | operationId: createPersonalAccessToken 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - personal access tokens 10 | requestBody: 11 | required: true 12 | content: 13 | application/json: 14 | schema: 15 | $ref: '../../components/schemas/personal_access_tokens/Create.yaml' 16 | responses: 17 | '200': 18 | description: Successfully created token 19 | content: 20 | application/json: 21 | schema: 22 | allOf: 23 | - $ref: '../../components/schemas/personal_access_tokens/Token.yaml' 24 | - $ref: '../../components/schemas/personal_access_tokens/Create_response.yaml' 25 | '401': 26 | $ref: '../../components/responses/Unauthorized.yaml' 27 | '422': 28 | $ref: '../../components/responses/UnprocessableEntity.yaml' 29 | get: 30 | summary: List personal access tokens 31 | description: Returns a list of personal access tokens for the authenticated user. 32 | operationId: listPersonalAccessTokens 33 | security: 34 | - cookieAuth: [] 35 | - bearerAuth: [] 36 | tags: 37 | - personal access tokens 38 | responses: 39 | '200': 40 | description: Successfully returned a list of personal access tokens 41 | content: 42 | application/json: 43 | schema: 44 | $ref: '../../components/schemas/personal_access_tokens/List.yaml' 45 | '401': 46 | $ref: '../../components/responses/Unauthorized.yaml' 47 | -------------------------------------------------------------------------------- /openapi/paths/personal_access_tokens/personal_access_tokens_{id}.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: Get a personal access token 3 | description: Returns a personal access token for the authenticated user. 4 | operationId: getPersonalAccessToken 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - personal access tokens 10 | parameters: 11 | - name: id 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | responses: 17 | '200': 18 | description: Successfully returned a personal access token 19 | content: 20 | application/json: 21 | schema: 22 | $ref: '../../components/schemas/personal_access_tokens/Token.yaml' 23 | '401': 24 | $ref: '../../components/responses/Unauthorized.yaml' 25 | delete: 26 | summary: Revoke a personal access token 27 | description: Revokes a personal access token for the authenticated user. 28 | operationId: revokePersonalAccessToken 29 | security: 30 | - cookieAuth: [] 31 | - bearerAuth: [] 32 | tags: 33 | - personal access tokens 34 | parameters: 35 | - name: id 36 | in: path 37 | required: true 38 | schema: 39 | type: string 40 | responses: 41 | '204': 42 | description: Successfully revoked a personal access token 43 | '401': 44 | $ref: '../../components/responses/Unauthorized.yaml' 45 | -------------------------------------------------------------------------------- /openapi/paths/tasks/tasks.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | summary: Create a task 3 | description: Returns newly created task. 4 | operationId: createTask 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - tasks 10 | requestBody: 11 | required: true 12 | content: 13 | application/json: 14 | schema: 15 | $ref: '../../components/schemas/tasks/Create.yaml' 16 | responses: 17 | '200': 18 | description: Successfully created task 19 | content: 20 | application/json: 21 | schema: 22 | $ref: '../../components/schemas/tasks/Task.yaml' 23 | '401': 24 | $ref: '../../components/responses/Unauthorized.yaml' 25 | '422': 26 | $ref: '../../components/responses/UnprocessableEntity.yaml' 27 | get: 28 | summary: List tasks 29 | description: Returns a list of tasks. 30 | operationId: findTasks 31 | security: 32 | - cookieAuth: [] 33 | - bearerAuth: [] 34 | tags: 35 | - tasks 36 | parameters: 37 | - name: created_by 38 | in: query 39 | description: Created by 40 | schema: 41 | type: string 42 | - name: completed 43 | in: query 44 | description: Completed 45 | schema: 46 | type: string 47 | - name: q 48 | in: query 49 | description: Query 50 | schema: 51 | type: array 52 | items: 53 | type: string 54 | - name: per_page 55 | in: query 56 | description: Number of tasks to return per page 57 | schema: 58 | type: integer 59 | minimum: 1 60 | maximum: 100 61 | default: 10 62 | - name: page 63 | in: query 64 | description: Page 65 | schema: 66 | type: integer 67 | minimum: 1 68 | default: 1 69 | responses: 70 | '200': 71 | description: Successfully returned a list of tasks 72 | content: 73 | application/json: 74 | schema: 75 | $ref: '../../components/schemas/tasks/List.yaml' 76 | headers: 77 | Link: 78 | schema: 79 | $ref: '../../components/headers/Link.yaml' 80 | X-Next-Page: 81 | schema: 82 | $ref: '../../components/headers/X-Next-Page.yaml' 83 | X-Page: 84 | schema: 85 | $ref: '../../components/headers/X-Page.yaml' 86 | X-Per-Page: 87 | schema: 88 | $ref: '../../components/headers/X-Per-Page.yaml' 89 | X-Prev-Page: 90 | schema: 91 | $ref: '../../components/headers/X-Prev-Page.yaml' 92 | X-Total: 93 | schema: 94 | $ref: '../../components/headers/X-Total.yaml' 95 | X-Total-Pages: 96 | schema: 97 | $ref: '../../components/headers/X-Total-Pages.yaml' 98 | '401': 99 | $ref: '../../components/responses/Unauthorized.yaml' 100 | -------------------------------------------------------------------------------- /openapi/paths/tasks/{id}.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: Get a task 3 | description: Returns a task. 4 | operationId: getTask 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - tasks 10 | parameters: 11 | - name: id 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | responses: 17 | '200': 18 | description: Successfully returned a task 19 | content: 20 | application/json: 21 | schema: 22 | $ref: '../../components/schemas/tasks/Task.yaml' 23 | '401': 24 | $ref: '../../components/responses/Unauthorized.yaml' 25 | '410': 26 | $ref: '../../components/responses/Gone.yaml' 27 | patch: 28 | summary: Update a task 29 | description: Returns the updated task. 30 | operationId: updateTask 31 | security: 32 | - cookieAuth: [] 33 | - bearerAuth: [] 34 | tags: 35 | - tasks 36 | parameters: 37 | - name: id 38 | in: path 39 | required: true 40 | schema: 41 | type: string 42 | requestBody: 43 | required: true 44 | content: 45 | application/json: 46 | schema: 47 | $ref: '../../components/schemas/tasks/Update.yaml' 48 | responses: 49 | '200': 50 | description: Successfully updated a task 51 | content: 52 | application/json: 53 | schema: 54 | $ref: '../../components/schemas/tasks/Task.yaml' 55 | '401': 56 | $ref: '../../components/responses/Unauthorized.yaml' 57 | '403': 58 | $ref: '../../components/responses/Forbidden.yaml' 59 | '410': 60 | $ref: '../../components/responses/Gone.yaml' 61 | '422': 62 | $ref: '../../components/responses/UnprocessableEntity.yaml' 63 | delete: 64 | summary: Delete a task 65 | description: Deletes a task. 66 | operationId: deleteTask 67 | security: 68 | - cookieAuth: [] 69 | - bearerAuth: [] 70 | tags: 71 | - tasks 72 | parameters: 73 | - name: id 74 | in: path 75 | required: true 76 | schema: 77 | type: string 78 | responses: 79 | '204': 80 | description: Successfully deleted a task 81 | '401': 82 | $ref: '../../components/responses/Unauthorized.yaml' 83 | '403': 84 | $ref: '../../components/responses/Forbidden.yaml' 85 | '410': 86 | $ref: '../../components/responses/Gone.yaml' 87 | -------------------------------------------------------------------------------- /openapi/paths/tasks/{id}_transition.yaml: -------------------------------------------------------------------------------- 1 | put: 2 | summary: Transition a task 3 | description: Returns the transitioned task. 4 | operationId: transitionTask 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - tasks 10 | parameters: 11 | - name: id 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | requestBody: 17 | required: true 18 | content: 19 | application/json: 20 | schema: 21 | $ref: '../../components/schemas/tasks/Transition.yaml' 22 | responses: 23 | '200': 24 | description: Successfully transitioned a task 25 | content: 26 | application/json: 27 | schema: 28 | $ref: '../../components/schemas/tasks/Task.yaml' 29 | '401': 30 | $ref: '../../components/responses/Unauthorized.yaml' 31 | '403': 32 | $ref: '../../components/responses/Forbidden.yaml' 33 | '410': 34 | $ref: '../../components/responses/Gone.yaml' 35 | '422': 36 | $ref: '../../components/responses/UnprocessableEntity.yaml' 37 | -------------------------------------------------------------------------------- /openapi/paths/users/me.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: Get current user 3 | description: Returns the current user. 4 | operationId: getCurrentUser 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | responses: 11 | '200': 12 | description: Successfully returned the current user 13 | content: 14 | application/json: 15 | schema: 16 | $ref: '../../components/schemas/users/me/CurrentUser.yaml' 17 | '401': 18 | $ref: '../../components/responses/Unauthorized.yaml' 19 | patch: 20 | summary: Update current user 21 | description: Returns the updated current user. 22 | operationId: updateCurrentUser 23 | security: 24 | - cookieAuth: [] 25 | - bearerAuth: [] 26 | tags: 27 | - users 28 | requestBody: 29 | required: true 30 | content: 31 | application/json: 32 | schema: 33 | $ref: '../../components/schemas/users/me/Update.yaml' 34 | responses: 35 | '200': 36 | description: Successfully returned current user modifications 37 | content: 38 | application/json: 39 | schema: 40 | $ref: '../../components/schemas/users/me/CurrentUser.yaml' 41 | '401': 42 | $ref: '../../components/responses/Unauthorized.yaml' 43 | '422': 44 | $ref: '../../components/responses/UnprocessableEntity.yaml' 45 | -------------------------------------------------------------------------------- /openapi/paths/users/users.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: List users 3 | description: Returns a list of users. Admin or higher role required. 4 | operationId: listUsers 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | parameters: 11 | - name: per_page 12 | in: query 13 | description: Number of users to return per page 14 | schema: 15 | type: integer 16 | minimum: 1 17 | maximum: 100 18 | default: 10 19 | - name: page 20 | in: query 21 | description: Page 22 | schema: 23 | type: integer 24 | minimum: 1 25 | default: 1 26 | responses: 27 | '200': 28 | description: Successfully returned a list of users 29 | content: 30 | application/json: 31 | schema: 32 | $ref: '../../components/schemas/users/List.yaml' 33 | headers: 34 | Link: 35 | schema: 36 | $ref: '../../components/headers/Link.yaml' 37 | X-Next-Page: 38 | schema: 39 | $ref: '../../components/headers/X-Next-Page.yaml' 40 | X-Page: 41 | schema: 42 | $ref: '../../components/headers/X-Page.yaml' 43 | X-Per-Page: 44 | schema: 45 | $ref: '../../components/headers/X-Per-Page.yaml' 46 | X-Prev-Page: 47 | schema: 48 | $ref: '../../components/headers/X-Prev-Page.yaml' 49 | X-Total: 50 | schema: 51 | $ref: '../../components/headers/X-Total.yaml' 52 | X-Total-Pages: 53 | schema: 54 | $ref: '../../components/headers/X-Total-Pages.yaml' 55 | '401': 56 | $ref: '../../components/responses/Unauthorized.yaml' 57 | '403': 58 | $ref: '../../components/responses/Forbidden.yaml' 59 | -------------------------------------------------------------------------------- /openapi/paths/users/{username}.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | summary: Get a user 3 | description: Returns a single user. Admin or higher role will return more fields. 4 | operationId: getUser 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | parameters: 11 | - name: username 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | responses: 17 | '200': 18 | description: Successfully returned a user 19 | content: 20 | application/json: 21 | schema: 22 | oneOf: 23 | - $ref: '../../components/schemas/users/me/CurrentUser.yaml' 24 | - $ref: '../../components/schemas/users/User.yaml' 25 | '401': 26 | $ref: '../../components/responses/Unauthorized.yaml' 27 | '410': 28 | $ref: '../../components/responses/Gone.yaml' 29 | patch: 30 | summary: Update user 31 | description: Returns the updated user. Admin or higher role required. 32 | operationId: updateUser 33 | security: 34 | - cookieAuth: [] 35 | - bearerAuth: [] 36 | tags: 37 | - users 38 | parameters: 39 | - name: username 40 | in: path 41 | required: true 42 | schema: 43 | type: string 44 | requestBody: 45 | required: true 46 | content: 47 | application/json: 48 | schema: 49 | $ref: '../../components/schemas/users/Update.yaml' 50 | responses: 51 | '200': 52 | description: Successfully returned user modifications 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '../../components/schemas/users/User.yaml' 57 | '401': 58 | $ref: '../../components/responses/Unauthorized.yaml' 59 | '403': 60 | $ref: '../../components/responses/Forbidden.yaml' 61 | '410': 62 | $ref: '../../components/responses/Gone.yaml' 63 | '422': 64 | $ref: '../../components/responses/UnprocessableEntity.yaml' 65 | -------------------------------------------------------------------------------- /openapi/paths/users/{username}_ban.yaml: -------------------------------------------------------------------------------- 1 | put: 2 | summary: Ban a user 3 | description: Bans a user. Admin or higher role required. 4 | operationId: banUser 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | parameters: 11 | - name: username 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | responses: 17 | '204': 18 | description: Successfully banned user 19 | '401': 20 | $ref: '../../components/responses/Unauthorized.yaml' 21 | '403': 22 | $ref: '../../components/responses/Forbidden.yaml' 23 | '409': 24 | $ref: '../../components/responses/Conflict.yaml' 25 | '410': 26 | $ref: '../../components/responses/Gone.yaml' 27 | delete: 28 | summary: Unban a user 29 | description: Unbans a user. Admin or higher role required. 30 | operationId: unbanUser 31 | security: 32 | - cookieAuth: [] 33 | - bearerAuth: [] 34 | tags: 35 | - users 36 | parameters: 37 | - name: username 38 | in: path 39 | required: true 40 | schema: 41 | type: string 42 | responses: 43 | '204': 44 | description: Successfully unbanned user 45 | '401': 46 | $ref: '../../components/responses/Unauthorized.yaml' 47 | '403': 48 | $ref: '../../components/responses/Forbidden.yaml' 49 | '410': 50 | $ref: '../../components/responses/Gone.yaml' 51 | -------------------------------------------------------------------------------- /openapi/paths/users/{username}_lock.yaml: -------------------------------------------------------------------------------- 1 | put: 2 | summary: Lock a user 3 | description: Locks a user. Admin or higher role required. 4 | operationId: lockUser 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | parameters: 11 | - name: username 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | responses: 17 | '204': 18 | description: Successfully locked user 19 | '401': 20 | $ref: '../../components/responses/Unauthorized.yaml' 21 | '403': 22 | $ref: '../../components/responses/Forbidden.yaml' 23 | '409': 24 | $ref: '../../components/responses/Conflict.yaml' 25 | '410': 26 | $ref: '../../components/responses/Gone.yaml' 27 | delete: 28 | summary: Unlock a user 29 | description: Unlocks a user. Admin or higher role required. 30 | operationId: unlockUser 31 | security: 32 | - cookieAuth: [] 33 | - bearerAuth: [] 34 | tags: 35 | - users 36 | parameters: 37 | - name: username 38 | in: path 39 | required: true 40 | schema: 41 | type: string 42 | responses: 43 | '204': 44 | description: Successfully unlocked user 45 | '401': 46 | $ref: '../../components/responses/Unauthorized.yaml' 47 | '403': 48 | $ref: '../../components/responses/Forbidden.yaml' 49 | '410': 50 | $ref: '../../components/responses/Gone.yaml' 51 | -------------------------------------------------------------------------------- /openapi/paths/users/{username}_roles_{role}.yaml: -------------------------------------------------------------------------------- 1 | put: 2 | summary: Add user role 3 | description: Adds a role to the user. Admin or higher role required. 4 | operationId: addRole 5 | security: 6 | - cookieAuth: [] 7 | - bearerAuth: [] 8 | tags: 9 | - users 10 | parameters: 11 | - name: username 12 | in: path 13 | required: true 14 | schema: 15 | type: string 16 | - name: role 17 | in: path 18 | required: true 19 | schema: 20 | type: string 21 | enum: ['user', 'admin', 'super'] 22 | responses: 23 | '204': 24 | description: Successfully added role 25 | '401': 26 | $ref: '../../components/responses/Unauthorized.yaml' 27 | '403': 28 | $ref: '../../components/responses/Forbidden.yaml' 29 | '409': 30 | $ref: '../../components/responses/Conflict.yaml' 31 | '410': 32 | $ref: '../../components/responses/Gone.yaml' 33 | '422': 34 | $ref: '../../components/responses/UnprocessableEntity.yaml' 35 | delete: 36 | summary: Remove user role 37 | description: Removes a role from the user. Admin or higher role required. 38 | operationId: removeRole 39 | security: 40 | - cookieAuth: [] 41 | - bearerAuth: [] 42 | tags: 43 | - users 44 | parameters: 45 | - name: username 46 | in: path 47 | required: true 48 | schema: 49 | type: string 50 | - name: role 51 | in: path 52 | required: true 53 | schema: 54 | type: string 55 | enum: ['user', 'admin', 'super'] 56 | responses: 57 | '204': 58 | description: Successfully removed role 59 | '401': 60 | $ref: '../../components/responses/Unauthorized.yaml' 61 | '403': 62 | $ref: '../../components/responses/Forbidden.yaml' 63 | '410': 64 | $ref: '../../components/responses/Gone.yaml' 65 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "time" 8 | 9 | casbinMw "github.com/alexferl/echo-casbin" 10 | jwtMw "github.com/alexferl/echo-jwt" 11 | openapiMw "github.com/alexferl/echo-openapi" 12 | "github.com/alexferl/golib/http/api/server" 13 | "github.com/casbin/casbin/v2" 14 | "github.com/labstack/echo/v4" 15 | jwx "github.com/lestrrat-go/jwx/v2/jwt" 16 | "github.com/rs/zerolog/log" 17 | "github.com/spf13/viper" 18 | _ "go.uber.org/automaxprocs" 19 | 20 | "github.com/alexferl/echo-boilerplate/config" 21 | "github.com/alexferl/echo-boilerplate/data" 22 | "github.com/alexferl/echo-boilerplate/handlers" 23 | "github.com/alexferl/echo-boilerplate/mappers" 24 | "github.com/alexferl/echo-boilerplate/services" 25 | "github.com/alexferl/echo-boilerplate/util/hash" 26 | "github.com/alexferl/echo-boilerplate/util/jwt" 27 | ) 28 | 29 | var ( 30 | ErrBanned = errors.New("account banned") 31 | ErrLocked = errors.New("account locked") 32 | ErrCookieMissing = errors.New("missing access token cookie") 33 | ErrCSRFHeaderMissing = errors.New("missing CSRF token header") 34 | ErrCSRFInvalid = errors.New("invalid CSRF token") 35 | ErrTokenInvalid = errors.New("token invalid") 36 | ErrTokenMismatch = errors.New("token mismatch") 37 | ErrTokenRevoked = errors.New("token is revoked") 38 | ErrTokenExpired = errors.New("token is expired") 39 | ) 40 | 41 | func New() *server.Server { 42 | client, err := data.MewMongoClient() 43 | if err != nil { 44 | log.Panic().Err(err).Msg("failed creating mongo client") 45 | } 46 | 47 | openapi := openapiMw.NewHandler() 48 | 49 | patMapper := mappers.NewPersonalAccessToken(client) 50 | patSvc := services.NewPersonalAccessToken(patMapper) 51 | 52 | taskMapper := mappers.NewTask(client) 53 | taskSvc := services.NewTask(taskMapper) 54 | 55 | userMapper := mappers.NewUser(client) 56 | userSvc := services.NewUser(userMapper) 57 | 58 | return newServer(userSvc, patSvc, []handlers.Handler{ 59 | handlers.NewRootHandler(openapi), 60 | handlers.NewAuthHandler(openapi, userSvc), 61 | handlers.NewPersonalAccessTokenHandler(openapi, patSvc), 62 | handlers.NewTaskHandler(openapi, taskSvc), 63 | handlers.NewUserHandler(openapi, userSvc), 64 | }...) 65 | } 66 | 67 | func NewTestServer(userSvc handlers.UserService, patSvc handlers.PersonalAccessTokenService, handler ...handlers.Handler) *server.Server { 68 | c := config.New() 69 | c.BindFlags() 70 | 71 | viper.Set(config.CookiesEnabled, true) 72 | viper.Set(config.CSRFEnabled, true) 73 | 74 | return newServer(userSvc, patSvc, handler...) 75 | } 76 | 77 | func newServer(userSvc handlers.UserService, patSvc handlers.PersonalAccessTokenService, handler ...handlers.Handler) *server.Server { 78 | jwtConfig := jwtMw.Config{ 79 | Key: jwt.PrivateKey, 80 | UseRefreshToken: true, 81 | ExemptRoutes: map[string][]string{ 82 | "/": {http.MethodGet}, 83 | "/readyz": {http.MethodGet}, 84 | "/livez": {http.MethodGet}, 85 | "/docs": {http.MethodGet}, 86 | "/openapi/*": {http.MethodGet}, 87 | "/auth/login": {http.MethodPost}, 88 | "/auth/signup": {http.MethodPost}, 89 | "/oauth2/google/callback": {http.MethodGet}, 90 | "/oauth2/google/login": {http.MethodGet}, 91 | }, 92 | AfterParseFunc: func(c echo.Context, t jwx.Token, encodedToken string, src jwtMw.TokenSource) *echo.HTTPError { 93 | ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) 94 | defer cancel() 95 | 96 | user, err := userSvc.Read(ctx, t.Subject()) 97 | if err != nil { 98 | log.Error().Err(err).Msg("failed getting user") 99 | return echo.NewHTTPError(http.StatusServiceUnavailable) 100 | } 101 | 102 | c.Set("user", user) 103 | // set roles for casbin 104 | c.Set("roles", user.Roles) 105 | 106 | if user.IsBanned { 107 | return echo.NewHTTPError(http.StatusForbidden, ErrBanned.Error()) 108 | } 109 | if user.IsLocked { 110 | return echo.NewHTTPError(http.StatusForbidden, ErrLocked.Error()) 111 | } 112 | 113 | // CSRF 114 | if viper.GetBool(config.CookiesEnabled) && viper.GetBool(config.CSRFEnabled) { 115 | if src == jwtMw.Cookie { 116 | switch c.Request().Method { 117 | case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: 118 | default: // Validate token only for requests which are not defined as 'safe' by RFC7231 119 | cookie, err := c.Cookie(viper.GetString(config.JWTAccessTokenCookieName)) 120 | if err != nil { 121 | return echo.NewHTTPError(http.StatusBadRequest, ErrCookieMissing) 122 | } 123 | 124 | h := c.Request().Header.Get(viper.GetString(config.CSRFHeaderName)) 125 | if h == "" { 126 | return echo.NewHTTPError(http.StatusBadRequest, ErrCSRFHeaderMissing) 127 | } 128 | 129 | if !hash.ValidMAC([]byte(cookie.Value), []byte(h), []byte(viper.GetString(config.CSRFSecretKey))) { 130 | return echo.NewHTTPError(http.StatusForbidden, ErrCSRFInvalid) 131 | } 132 | } 133 | } 134 | } 135 | // Personal Access Tokens 136 | claims := t.PrivateClaims() 137 | typ := claims["type"] 138 | if typ == jwt.PersonalToken.String() { 139 | pat, err := patSvc.FindOne(ctx, t.Subject(), "") 140 | if err != nil { 141 | var se *services.Error 142 | if errors.As(err, &se) { 143 | if se.Kind == services.NotExist { 144 | return echo.NewHTTPError(http.StatusUnauthorized, ErrTokenInvalid) 145 | } 146 | } 147 | return echo.NewHTTPError(http.StatusServiceUnavailable) 148 | } 149 | 150 | if err = pat.Validate(encodedToken); err != nil { 151 | return echo.NewHTTPError(http.StatusUnauthorized, ErrTokenMismatch) 152 | } 153 | 154 | if pat.IsRevoked { 155 | return echo.NewHTTPError(http.StatusUnauthorized, ErrTokenRevoked) 156 | } 157 | 158 | if time.Now().After(*pat.ExpiresAt) { 159 | return echo.NewHTTPError(http.StatusUnauthorized, ErrTokenExpired) 160 | } 161 | } 162 | 163 | // set token_id globally 164 | log.Logger = log.Logger.With().Str("token_id", t.Subject()).Logger() 165 | 166 | return nil 167 | }, 168 | } 169 | 170 | enforcer, err := casbin.NewEnforcer(viper.GetString(config.CasbinModel), viper.GetString(config.CasbinPolicy)) 171 | if err != nil { 172 | log.Panic().Err(err).Msg("failed creating enforcer") 173 | } 174 | 175 | openAPIConfig := openapiMw.Config{ 176 | Schema: viper.GetString(config.OpenAPISchema), 177 | ExemptRoutes: map[string][]string{ 178 | "/": {http.MethodGet}, 179 | "/readyz": {http.MethodGet}, 180 | "/livez": {http.MethodGet}, 181 | "/docs": {http.MethodGet}, 182 | "/openapi/*": {http.MethodGet}, 183 | "/oauth2/google/callback": {http.MethodGet}, 184 | "/oauth2/google/login": {http.MethodGet}, 185 | }, 186 | } 187 | 188 | s := server.New() 189 | 190 | s.Use( 191 | jwtMw.JWTWithConfig(jwtConfig), 192 | casbinMw.Casbin(enforcer), 193 | openapiMw.OpenAPIWithConfig(openAPIConfig), 194 | ) 195 | 196 | for _, h := range handler { 197 | h.Register(s) 198 | } 199 | 200 | s.File("/docs", "./docs/index.html") 201 | s.Static("/openapi/", "./openapi") 202 | 203 | return s 204 | } 205 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/alexferl/echo-openapi" 13 | api "github.com/alexferl/golib/http/api/server" 14 | "github.com/labstack/echo/v4" 15 | "github.com/stretchr/testify/mock" 16 | "github.com/stretchr/testify/suite" 17 | 18 | "github.com/alexferl/echo-boilerplate/handlers" 19 | "github.com/alexferl/echo-boilerplate/models" 20 | "github.com/alexferl/echo-boilerplate/services" 21 | _ "github.com/alexferl/echo-boilerplate/testing" 22 | "github.com/alexferl/echo-boilerplate/util/cookie" 23 | ) 24 | 25 | type ServerTestSuite struct { 26 | suite.Suite 27 | svc *handlers.MockUserService 28 | patSvc *handlers.MockPersonalAccessTokenService 29 | server *api.Server 30 | user *models.User 31 | accessToken []byte 32 | admin *models.User 33 | } 34 | 35 | func (s *ServerTestSuite) SetupTest() { 36 | svc := handlers.NewMockUserService(s.T()) 37 | patSvc := handlers.NewMockPersonalAccessTokenService(s.T()) 38 | h := handlers.NewUserHandler(openapi.NewHandler(), svc) 39 | 40 | admin := models.NewUserWithRole("test@example.com", "test", models.AdminRole) 41 | user := models.NewUser("test@example.com", "test") 42 | user.Id = "1000" 43 | user.Create(user.Id) 44 | access, _, _ := user.Login() 45 | 46 | s.svc = svc 47 | s.patSvc = patSvc 48 | s.server = NewTestServer(svc, patSvc, h) 49 | s.user = user 50 | s.accessToken = access 51 | s.admin = admin 52 | } 53 | 54 | func TestServerTestSuite(t *testing.T) { 55 | suite.Run(t, new(ServerTestSuite)) 56 | } 57 | 58 | func (s *ServerTestSuite) TestServer_503() { 59 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 60 | req.Header.Set("Content-Type", "application/json") 61 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken)) 62 | resp := httptest.NewRecorder() 63 | 64 | s.svc.EXPECT(). 65 | Read(mock.Anything, mock.Anything). 66 | Return(nil, errors.New("")).Once() 67 | 68 | s.server.ServeHTTP(resp, req) 69 | 70 | s.Assert().Equal(http.StatusServiceUnavailable, resp.Code) 71 | } 72 | 73 | func (s *ServerTestSuite) TestServer_403_Banned() { 74 | _ = s.user.Ban(s.admin) 75 | 76 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 77 | req.Header.Set("Content-Type", "application/json") 78 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken)) 79 | resp := httptest.NewRecorder() 80 | 81 | s.svc.EXPECT(). 82 | Read(mock.Anything, mock.Anything). 83 | Return(s.user, nil).Once() 84 | 85 | s.server.ServeHTTP(resp, req) 86 | 87 | var result echo.HTTPError 88 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 89 | 90 | s.Assert().Equal(http.StatusForbidden, resp.Code) 91 | s.Assert().Equal(ErrBanned.Error(), result.Message) 92 | } 93 | 94 | func (s *ServerTestSuite) TestServer_403_Locked() { 95 | _ = s.user.Lock(s.admin) 96 | 97 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 98 | req.Header.Set("Content-Type", "application/json") 99 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.accessToken)) 100 | resp := httptest.NewRecorder() 101 | 102 | s.svc.EXPECT(). 103 | Read(mock.Anything, mock.Anything). 104 | Return(s.user, nil).Once() 105 | 106 | s.server.ServeHTTP(resp, req) 107 | 108 | var result echo.HTTPError 109 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 110 | 111 | s.Assert().Equal(http.StatusForbidden, resp.Code) 112 | s.Assert().Equal(ErrLocked.Error(), result.Message) 113 | } 114 | 115 | func (s *ServerTestSuite) TestServer_400_CSRF_Header_Missing() { 116 | req := httptest.NewRequest(http.MethodPatch, "/me", nil) 117 | req.Header.Set("Content-Type", "application/json") 118 | req.AddCookie(cookie.NewAccessToken(s.accessToken)) 119 | resp := httptest.NewRecorder() 120 | 121 | s.svc.EXPECT(). 122 | Read(mock.Anything, mock.Anything). 123 | Return(s.user, nil).Once() 124 | 125 | s.server.ServeHTTP(resp, req) 126 | 127 | var result echo.HTTPError 128 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 129 | 130 | s.Assert().Equal(http.StatusBadRequest, resp.Code) 131 | s.Assert().Equal(ErrCSRFHeaderMissing.Error(), result.Message) 132 | } 133 | 134 | func (s *ServerTestSuite) TestServer_400_CSRF_Header_Invalid() { 135 | req := httptest.NewRequest(http.MethodPatch, "/me", nil) 136 | req.Header.Set("Content-Type", "application/json") 137 | req.AddCookie(cookie.NewAccessToken(s.accessToken)) 138 | req.Header.Add("X-CSRF-Token", "token") 139 | resp := httptest.NewRecorder() 140 | 141 | s.svc.EXPECT(). 142 | Read(mock.Anything, mock.Anything). 143 | Return(s.user, nil).Once() 144 | 145 | s.server.ServeHTTP(resp, req) 146 | 147 | var result echo.HTTPError 148 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 149 | 150 | s.Assert().Equal(http.StatusForbidden, resp.Code) 151 | s.Assert().Equal(ErrCSRFInvalid.Error(), result.Message) 152 | } 153 | 154 | func (s *ServerTestSuite) TestServer_PAT_401_Token_Invalid() { 155 | pat, _ := models.NewPersonalAccessToken( 156 | s.user.Id, 157 | fmt.Sprintf("my_token"), 158 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 159 | ) 160 | 161 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 162 | req.Header.Set("Content-Type", "application/json") 163 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 164 | resp := httptest.NewRecorder() 165 | 166 | _ = pat.Encrypt() 167 | 168 | s.svc.EXPECT(). 169 | Read(mock.Anything, mock.Anything). 170 | Return(s.user, nil).Once() 171 | 172 | s.patSvc.EXPECT(). 173 | FindOne(mock.Anything, mock.Anything, mock.Anything). 174 | Return(nil, services.NewError(nil, services.NotExist, "")).Once() 175 | 176 | s.server.ServeHTTP(resp, req) 177 | 178 | var result echo.HTTPError 179 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 180 | 181 | s.Assert().Equal(http.StatusUnauthorized, resp.Code) 182 | s.Assert().Equal(ErrTokenInvalid.Error(), result.Message) 183 | } 184 | 185 | func (s *ServerTestSuite) TestServer_PAT_401_Token_Mismatch() { 186 | pat, _ := models.NewPersonalAccessToken( 187 | s.user.Id, 188 | fmt.Sprintf("my_token"), 189 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 190 | ) 191 | 192 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 193 | req.Header.Set("Content-Type", "application/json") 194 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 195 | resp := httptest.NewRecorder() 196 | 197 | s.svc.EXPECT(). 198 | Read(mock.Anything, mock.Anything). 199 | Return(s.user, nil).Once() 200 | 201 | s.patSvc.EXPECT(). 202 | FindOne(mock.Anything, mock.Anything, mock.Anything). 203 | Return(pat, nil).Once() 204 | 205 | s.server.ServeHTTP(resp, req) 206 | 207 | var result echo.HTTPError 208 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 209 | 210 | s.Assert().Equal(http.StatusUnauthorized, resp.Code) 211 | s.Assert().Equal(ErrTokenMismatch.Error(), result.Message) 212 | } 213 | 214 | func (s *ServerTestSuite) TestServer_PAT_401_Revoked() { 215 | pat, _ := models.NewPersonalAccessToken( 216 | s.user.Id, 217 | fmt.Sprintf("my_token"), 218 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 219 | ) 220 | 221 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 222 | req.Header.Set("Content-Type", "application/json") 223 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 224 | resp := httptest.NewRecorder() 225 | 226 | _ = pat.Encrypt() 227 | pat.IsRevoked = true 228 | 229 | s.svc.EXPECT(). 230 | Read(mock.Anything, mock.Anything). 231 | Return(s.user, nil).Once() 232 | 233 | s.patSvc.EXPECT(). 234 | FindOne(mock.Anything, mock.Anything, mock.Anything). 235 | Return(pat, nil).Once() 236 | 237 | s.server.ServeHTTP(resp, req) 238 | 239 | var result echo.HTTPError 240 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 241 | 242 | s.Assert().Equal(http.StatusUnauthorized, resp.Code) 243 | s.Assert().Equal(ErrTokenRevoked.Error(), result.Message) 244 | } 245 | 246 | func (s *ServerTestSuite) TestServer_PAT_401_Expired() { 247 | pat, _ := models.NewPersonalAccessToken( 248 | s.user.Id, 249 | fmt.Sprintf("my_token"), 250 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 251 | ) 252 | 253 | past := time.Now().Add(-(7 * 24) * time.Hour) 254 | pat.ExpiresAt = &past 255 | 256 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 257 | req.Header.Set("Content-Type", "application/json") 258 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 259 | resp := httptest.NewRecorder() 260 | 261 | _ = pat.Encrypt() 262 | 263 | s.svc.EXPECT(). 264 | Read(mock.Anything, mock.Anything). 265 | Return(s.user, nil).Once() 266 | 267 | s.patSvc.EXPECT(). 268 | FindOne(mock.Anything, mock.Anything, mock.Anything). 269 | Return(pat, nil).Once() 270 | 271 | s.server.ServeHTTP(resp, req) 272 | 273 | var result echo.HTTPError 274 | _ = json.Unmarshal(resp.Body.Bytes(), &result) 275 | 276 | s.Assert().Equal(http.StatusUnauthorized, resp.Code) 277 | s.Assert().Equal(ErrTokenExpired.Error(), result.Message) 278 | } 279 | 280 | func (s *ServerTestSuite) TestServer_PAT_503() { 281 | pat, _ := models.NewPersonalAccessToken( 282 | s.user.Id, 283 | fmt.Sprintf("my_token"), 284 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 285 | ) 286 | 287 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 288 | req.Header.Set("Content-Type", "application/json") 289 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 290 | resp := httptest.NewRecorder() 291 | 292 | s.svc.EXPECT(). 293 | Read(mock.Anything, mock.Anything). 294 | Return(s.user, nil).Once() 295 | 296 | s.patSvc.EXPECT(). 297 | FindOne(mock.Anything, mock.Anything, mock.Anything). 298 | Return(nil, errors.New("")).Once() 299 | 300 | s.server.ServeHTTP(resp, req) 301 | 302 | s.Assert().Equal(http.StatusServiceUnavailable, resp.Code) 303 | } 304 | 305 | func (s *ServerTestSuite) TestServer_PAT_200() { 306 | pat, _ := models.NewPersonalAccessToken( 307 | s.user.Id, 308 | fmt.Sprintf("my_token"), 309 | time.Now().Add((7*24)*time.Hour).Format("2006-01-02"), 310 | ) 311 | 312 | req := httptest.NewRequest(http.MethodGet, "/me", nil) 313 | req.Header.Set("Content-Type", "application/json") 314 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pat.Token)) 315 | resp := httptest.NewRecorder() 316 | 317 | _ = pat.Encrypt() 318 | 319 | s.svc.EXPECT(). 320 | Read(mock.Anything, mock.Anything). 321 | Return(s.user, nil).Once() 322 | 323 | s.patSvc.EXPECT(). 324 | FindOne(mock.Anything, mock.Anything, mock.Anything). 325 | Return(pat, nil).Once() 326 | 327 | s.svc.EXPECT(). 328 | Read(mock.Anything, mock.Anything). 329 | Return(s.user, nil).Once() 330 | 331 | s.server.ServeHTTP(resp, req) 332 | 333 | s.Assert().Equal(http.StatusOK, resp.Code) 334 | } 335 | -------------------------------------------------------------------------------- /services/error.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import "fmt" 4 | 5 | // Error represents an error that could be wrapping another error. 6 | type Error struct { 7 | Internal error 8 | Kind Kind 9 | Message string 10 | } 11 | 12 | // Kind defines supported error types. 13 | type Kind uint8 14 | 15 | const ( 16 | Other Kind = iota + 1 // Unclassified error. 17 | Exist // Item already exist. 18 | NotExist // Item does not exist. 19 | Deleted // Item was deleted. 20 | Conflict 21 | Permission 22 | ) 23 | 24 | func (k Kind) String() string { 25 | return [...]string{"other", "exist", "not_exist", "deleted", "conflict", "permission"}[k-1] 26 | } 27 | 28 | // NewError instantiates a new error. 29 | func NewError(err error, code Kind, message string) error { 30 | e := &Error{ 31 | Internal: err, 32 | Kind: code, 33 | Message: message, 34 | } 35 | return e 36 | } 37 | 38 | // Error returns the message, when wrapping errors the wrapped error is appended. 39 | func (e *Error) Error() string { 40 | if e.Internal == nil { 41 | return fmt.Sprintf("kind=%s, message=%v", e.Kind, e.Message) 42 | } 43 | return fmt.Sprintf("kind=%s, message=%v, internal=%v", e.Kind, e.Message, e.Internal) 44 | } 45 | 46 | // Unwrap returns the wrapped error, if any. 47 | func (e *Error) Unwrap() error { 48 | return e.Internal 49 | } 50 | -------------------------------------------------------------------------------- /services/error_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewError(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | msg string 15 | kind Kind 16 | }{ 17 | {"Other", "other", Other}, 18 | {"Exist", "exist", Exist}, 19 | {"NotExist", "not_exist", NotExist}, 20 | {"Deleted", "deleted", Deleted}, 21 | } 22 | 23 | for _, tc := range testCases { 24 | t.Run(tc.name, func(t *testing.T) { 25 | e := NewError(nil, tc.kind, tc.msg) 26 | var se *Error 27 | if errors.As(e, &se) { 28 | assert.Equal(t, tc.msg, se.Message) 29 | assert.Equal(t, tc.kind, se.Kind) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | func TestKind(t *testing.T) { 36 | testCases := []struct { 37 | name string 38 | msg string 39 | kind Kind 40 | }{ 41 | {"Other", "other", Other}, 42 | {"Exist", "exist", Exist}, 43 | {"NotExist", "not_exist", NotExist}, 44 | {"Deleted", "deleted", Deleted}, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | assert.Equal(t, tc.msg, tc.kind.String()) 50 | }) 51 | } 52 | } 53 | 54 | func TestError_Error(t *testing.T) { 55 | errMsg := "my error" 56 | err := errors.New(errMsg) 57 | msg := "my msg" 58 | e := NewError(err, Other, msg) 59 | assert.Equal(t, fmt.Sprintf("kind=other, message=%s, internal=%s", msg, errMsg), e.Error()) 60 | } 61 | 62 | func TestError_Error_No_Internal(t *testing.T) { 63 | msg := "my msg" 64 | e := NewError(nil, Other, msg) 65 | assert.Equal(t, fmt.Sprintf("kind=other, message=%s", msg), e.Error()) 66 | } 67 | 68 | func TestError_Unwrap(t *testing.T) { 69 | errMsg := "my error" 70 | err := errors.New(errMsg) 71 | e := NewError(err, Other, "") 72 | assert.Equal(t, errMsg, errors.Unwrap(e).Error()) 73 | } 74 | -------------------------------------------------------------------------------- /services/mock_TaskMapper.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.42.0. DO NOT EDIT. 2 | 3 | package services 4 | 5 | import ( 6 | context "context" 7 | 8 | models "github.com/alexferl/echo-boilerplate/models" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MockTaskMapper is an autogenerated mock type for the TaskMapper type 13 | type MockTaskMapper struct { 14 | mock.Mock 15 | } 16 | 17 | type MockTaskMapper_Expecter struct { 18 | mock *mock.Mock 19 | } 20 | 21 | func (_m *MockTaskMapper) EXPECT() *MockTaskMapper_Expecter { 22 | return &MockTaskMapper_Expecter{mock: &_m.Mock} 23 | } 24 | 25 | // Create provides a mock function with given fields: ctx, model 26 | func (_m *MockTaskMapper) Create(ctx context.Context, model *models.Task) (*models.Task, error) { 27 | ret := _m.Called(ctx, model) 28 | 29 | if len(ret) == 0 { 30 | panic("no return value specified for Create") 31 | } 32 | 33 | var r0 *models.Task 34 | var r1 error 35 | if rf, ok := ret.Get(0).(func(context.Context, *models.Task) (*models.Task, error)); ok { 36 | return rf(ctx, model) 37 | } 38 | if rf, ok := ret.Get(0).(func(context.Context, *models.Task) *models.Task); ok { 39 | r0 = rf(ctx, model) 40 | } else { 41 | if ret.Get(0) != nil { 42 | r0 = ret.Get(0).(*models.Task) 43 | } 44 | } 45 | 46 | if rf, ok := ret.Get(1).(func(context.Context, *models.Task) error); ok { 47 | r1 = rf(ctx, model) 48 | } else { 49 | r1 = ret.Error(1) 50 | } 51 | 52 | return r0, r1 53 | } 54 | 55 | // MockTaskMapper_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' 56 | type MockTaskMapper_Create_Call struct { 57 | *mock.Call 58 | } 59 | 60 | // Create is a helper method to define mock.On call 61 | // - ctx context.Context 62 | // - model *models.Task 63 | func (_e *MockTaskMapper_Expecter) Create(ctx interface{}, model interface{}) *MockTaskMapper_Create_Call { 64 | return &MockTaskMapper_Create_Call{Call: _e.mock.On("Create", ctx, model)} 65 | } 66 | 67 | func (_c *MockTaskMapper_Create_Call) Run(run func(ctx context.Context, model *models.Task)) *MockTaskMapper_Create_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | run(args[0].(context.Context), args[1].(*models.Task)) 70 | }) 71 | return _c 72 | } 73 | 74 | func (_c *MockTaskMapper_Create_Call) Return(_a0 *models.Task, _a1 error) *MockTaskMapper_Create_Call { 75 | _c.Call.Return(_a0, _a1) 76 | return _c 77 | } 78 | 79 | func (_c *MockTaskMapper_Create_Call) RunAndReturn(run func(context.Context, *models.Task) (*models.Task, error)) *MockTaskMapper_Create_Call { 80 | _c.Call.Return(run) 81 | return _c 82 | } 83 | 84 | // Find provides a mock function with given fields: ctx, filter, limit, skip 85 | func (_m *MockTaskMapper) Find(ctx context.Context, filter interface{}, limit int, skip int) (int64, models.Tasks, error) { 86 | ret := _m.Called(ctx, filter, limit, skip) 87 | 88 | if len(ret) == 0 { 89 | panic("no return value specified for Find") 90 | } 91 | 92 | var r0 int64 93 | var r1 models.Tasks 94 | var r2 error 95 | if rf, ok := ret.Get(0).(func(context.Context, interface{}, int, int) (int64, models.Tasks, error)); ok { 96 | return rf(ctx, filter, limit, skip) 97 | } 98 | if rf, ok := ret.Get(0).(func(context.Context, interface{}, int, int) int64); ok { 99 | r0 = rf(ctx, filter, limit, skip) 100 | } else { 101 | r0 = ret.Get(0).(int64) 102 | } 103 | 104 | if rf, ok := ret.Get(1).(func(context.Context, interface{}, int, int) models.Tasks); ok { 105 | r1 = rf(ctx, filter, limit, skip) 106 | } else { 107 | if ret.Get(1) != nil { 108 | r1 = ret.Get(1).(models.Tasks) 109 | } 110 | } 111 | 112 | if rf, ok := ret.Get(2).(func(context.Context, interface{}, int, int) error); ok { 113 | r2 = rf(ctx, filter, limit, skip) 114 | } else { 115 | r2 = ret.Error(2) 116 | } 117 | 118 | return r0, r1, r2 119 | } 120 | 121 | // MockTaskMapper_Find_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Find' 122 | type MockTaskMapper_Find_Call struct { 123 | *mock.Call 124 | } 125 | 126 | // Find is a helper method to define mock.On call 127 | // - ctx context.Context 128 | // - filter interface{} 129 | // - limit int 130 | // - skip int 131 | func (_e *MockTaskMapper_Expecter) Find(ctx interface{}, filter interface{}, limit interface{}, skip interface{}) *MockTaskMapper_Find_Call { 132 | return &MockTaskMapper_Find_Call{Call: _e.mock.On("Find", ctx, filter, limit, skip)} 133 | } 134 | 135 | func (_c *MockTaskMapper_Find_Call) Run(run func(ctx context.Context, filter interface{}, limit int, skip int)) *MockTaskMapper_Find_Call { 136 | _c.Call.Run(func(args mock.Arguments) { 137 | run(args[0].(context.Context), args[1].(interface{}), args[2].(int), args[3].(int)) 138 | }) 139 | return _c 140 | } 141 | 142 | func (_c *MockTaskMapper_Find_Call) Return(_a0 int64, _a1 models.Tasks, _a2 error) *MockTaskMapper_Find_Call { 143 | _c.Call.Return(_a0, _a1, _a2) 144 | return _c 145 | } 146 | 147 | func (_c *MockTaskMapper_Find_Call) RunAndReturn(run func(context.Context, interface{}, int, int) (int64, models.Tasks, error)) *MockTaskMapper_Find_Call { 148 | _c.Call.Return(run) 149 | return _c 150 | } 151 | 152 | // FindOneById provides a mock function with given fields: ctx, id 153 | func (_m *MockTaskMapper) FindOneById(ctx context.Context, id string) (*models.Task, error) { 154 | ret := _m.Called(ctx, id) 155 | 156 | if len(ret) == 0 { 157 | panic("no return value specified for FindOneById") 158 | } 159 | 160 | var r0 *models.Task 161 | var r1 error 162 | if rf, ok := ret.Get(0).(func(context.Context, string) (*models.Task, error)); ok { 163 | return rf(ctx, id) 164 | } 165 | if rf, ok := ret.Get(0).(func(context.Context, string) *models.Task); ok { 166 | r0 = rf(ctx, id) 167 | } else { 168 | if ret.Get(0) != nil { 169 | r0 = ret.Get(0).(*models.Task) 170 | } 171 | } 172 | 173 | if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { 174 | r1 = rf(ctx, id) 175 | } else { 176 | r1 = ret.Error(1) 177 | } 178 | 179 | return r0, r1 180 | } 181 | 182 | // MockTaskMapper_FindOneById_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindOneById' 183 | type MockTaskMapper_FindOneById_Call struct { 184 | *mock.Call 185 | } 186 | 187 | // FindOneById is a helper method to define mock.On call 188 | // - ctx context.Context 189 | // - id string 190 | func (_e *MockTaskMapper_Expecter) FindOneById(ctx interface{}, id interface{}) *MockTaskMapper_FindOneById_Call { 191 | return &MockTaskMapper_FindOneById_Call{Call: _e.mock.On("FindOneById", ctx, id)} 192 | } 193 | 194 | func (_c *MockTaskMapper_FindOneById_Call) Run(run func(ctx context.Context, id string)) *MockTaskMapper_FindOneById_Call { 195 | _c.Call.Run(func(args mock.Arguments) { 196 | run(args[0].(context.Context), args[1].(string)) 197 | }) 198 | return _c 199 | } 200 | 201 | func (_c *MockTaskMapper_FindOneById_Call) Return(_a0 *models.Task, _a1 error) *MockTaskMapper_FindOneById_Call { 202 | _c.Call.Return(_a0, _a1) 203 | return _c 204 | } 205 | 206 | func (_c *MockTaskMapper_FindOneById_Call) RunAndReturn(run func(context.Context, string) (*models.Task, error)) *MockTaskMapper_FindOneById_Call { 207 | _c.Call.Return(run) 208 | return _c 209 | } 210 | 211 | // Update provides a mock function with given fields: ctx, model 212 | func (_m *MockTaskMapper) Update(ctx context.Context, model *models.Task) (*models.Task, error) { 213 | ret := _m.Called(ctx, model) 214 | 215 | if len(ret) == 0 { 216 | panic("no return value specified for Update") 217 | } 218 | 219 | var r0 *models.Task 220 | var r1 error 221 | if rf, ok := ret.Get(0).(func(context.Context, *models.Task) (*models.Task, error)); ok { 222 | return rf(ctx, model) 223 | } 224 | if rf, ok := ret.Get(0).(func(context.Context, *models.Task) *models.Task); ok { 225 | r0 = rf(ctx, model) 226 | } else { 227 | if ret.Get(0) != nil { 228 | r0 = ret.Get(0).(*models.Task) 229 | } 230 | } 231 | 232 | if rf, ok := ret.Get(1).(func(context.Context, *models.Task) error); ok { 233 | r1 = rf(ctx, model) 234 | } else { 235 | r1 = ret.Error(1) 236 | } 237 | 238 | return r0, r1 239 | } 240 | 241 | // MockTaskMapper_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' 242 | type MockTaskMapper_Update_Call struct { 243 | *mock.Call 244 | } 245 | 246 | // Update is a helper method to define mock.On call 247 | // - ctx context.Context 248 | // - model *models.Task 249 | func (_e *MockTaskMapper_Expecter) Update(ctx interface{}, model interface{}) *MockTaskMapper_Update_Call { 250 | return &MockTaskMapper_Update_Call{Call: _e.mock.On("Update", ctx, model)} 251 | } 252 | 253 | func (_c *MockTaskMapper_Update_Call) Run(run func(ctx context.Context, model *models.Task)) *MockTaskMapper_Update_Call { 254 | _c.Call.Run(func(args mock.Arguments) { 255 | run(args[0].(context.Context), args[1].(*models.Task)) 256 | }) 257 | return _c 258 | } 259 | 260 | func (_c *MockTaskMapper_Update_Call) Return(_a0 *models.Task, _a1 error) *MockTaskMapper_Update_Call { 261 | _c.Call.Return(_a0, _a1) 262 | return _c 263 | } 264 | 265 | func (_c *MockTaskMapper_Update_Call) RunAndReturn(run func(context.Context, *models.Task) (*models.Task, error)) *MockTaskMapper_Update_Call { 266 | _c.Call.Return(run) 267 | return _c 268 | } 269 | 270 | // NewMockTaskMapper creates a new instance of MockTaskMapper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 271 | // The first argument is typically a *testing.T value. 272 | func NewMockTaskMapper(t interface { 273 | mock.TestingT 274 | Cleanup(func()) 275 | }) *MockTaskMapper { 276 | mock := &MockTaskMapper{} 277 | mock.Mock.Test(t) 278 | 279 | t.Cleanup(func() { mock.AssertExpectations(t) }) 280 | 281 | return mock 282 | } 283 | -------------------------------------------------------------------------------- /services/mock_UserMapper.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.42.0. DO NOT EDIT. 2 | 3 | package services 4 | 5 | import ( 6 | context "context" 7 | 8 | models "github.com/alexferl/echo-boilerplate/models" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // MockUserMapper is an autogenerated mock type for the UserMapper type 13 | type MockUserMapper struct { 14 | mock.Mock 15 | } 16 | 17 | type MockUserMapper_Expecter struct { 18 | mock *mock.Mock 19 | } 20 | 21 | func (_m *MockUserMapper) EXPECT() *MockUserMapper_Expecter { 22 | return &MockUserMapper_Expecter{mock: &_m.Mock} 23 | } 24 | 25 | // Create provides a mock function with given fields: ctx, model 26 | func (_m *MockUserMapper) Create(ctx context.Context, model *models.User) (*models.User, error) { 27 | ret := _m.Called(ctx, model) 28 | 29 | if len(ret) == 0 { 30 | panic("no return value specified for Create") 31 | } 32 | 33 | var r0 *models.User 34 | var r1 error 35 | if rf, ok := ret.Get(0).(func(context.Context, *models.User) (*models.User, error)); ok { 36 | return rf(ctx, model) 37 | } 38 | if rf, ok := ret.Get(0).(func(context.Context, *models.User) *models.User); ok { 39 | r0 = rf(ctx, model) 40 | } else { 41 | if ret.Get(0) != nil { 42 | r0 = ret.Get(0).(*models.User) 43 | } 44 | } 45 | 46 | if rf, ok := ret.Get(1).(func(context.Context, *models.User) error); ok { 47 | r1 = rf(ctx, model) 48 | } else { 49 | r1 = ret.Error(1) 50 | } 51 | 52 | return r0, r1 53 | } 54 | 55 | // MockUserMapper_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' 56 | type MockUserMapper_Create_Call struct { 57 | *mock.Call 58 | } 59 | 60 | // Create is a helper method to define mock.On call 61 | // - ctx context.Context 62 | // - model *models.User 63 | func (_e *MockUserMapper_Expecter) Create(ctx interface{}, model interface{}) *MockUserMapper_Create_Call { 64 | return &MockUserMapper_Create_Call{Call: _e.mock.On("Create", ctx, model)} 65 | } 66 | 67 | func (_c *MockUserMapper_Create_Call) Run(run func(ctx context.Context, model *models.User)) *MockUserMapper_Create_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | run(args[0].(context.Context), args[1].(*models.User)) 70 | }) 71 | return _c 72 | } 73 | 74 | func (_c *MockUserMapper_Create_Call) Return(_a0 *models.User, _a1 error) *MockUserMapper_Create_Call { 75 | _c.Call.Return(_a0, _a1) 76 | return _c 77 | } 78 | 79 | func (_c *MockUserMapper_Create_Call) RunAndReturn(run func(context.Context, *models.User) (*models.User, error)) *MockUserMapper_Create_Call { 80 | _c.Call.Return(run) 81 | return _c 82 | } 83 | 84 | // Find provides a mock function with given fields: ctx, filter, limit, skip 85 | func (_m *MockUserMapper) Find(ctx context.Context, filter interface{}, limit int, skip int) (int64, models.Users, error) { 86 | ret := _m.Called(ctx, filter, limit, skip) 87 | 88 | if len(ret) == 0 { 89 | panic("no return value specified for Find") 90 | } 91 | 92 | var r0 int64 93 | var r1 models.Users 94 | var r2 error 95 | if rf, ok := ret.Get(0).(func(context.Context, interface{}, int, int) (int64, models.Users, error)); ok { 96 | return rf(ctx, filter, limit, skip) 97 | } 98 | if rf, ok := ret.Get(0).(func(context.Context, interface{}, int, int) int64); ok { 99 | r0 = rf(ctx, filter, limit, skip) 100 | } else { 101 | r0 = ret.Get(0).(int64) 102 | } 103 | 104 | if rf, ok := ret.Get(1).(func(context.Context, interface{}, int, int) models.Users); ok { 105 | r1 = rf(ctx, filter, limit, skip) 106 | } else { 107 | if ret.Get(1) != nil { 108 | r1 = ret.Get(1).(models.Users) 109 | } 110 | } 111 | 112 | if rf, ok := ret.Get(2).(func(context.Context, interface{}, int, int) error); ok { 113 | r2 = rf(ctx, filter, limit, skip) 114 | } else { 115 | r2 = ret.Error(2) 116 | } 117 | 118 | return r0, r1, r2 119 | } 120 | 121 | // MockUserMapper_Find_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Find' 122 | type MockUserMapper_Find_Call struct { 123 | *mock.Call 124 | } 125 | 126 | // Find is a helper method to define mock.On call 127 | // - ctx context.Context 128 | // - filter interface{} 129 | // - limit int 130 | // - skip int 131 | func (_e *MockUserMapper_Expecter) Find(ctx interface{}, filter interface{}, limit interface{}, skip interface{}) *MockUserMapper_Find_Call { 132 | return &MockUserMapper_Find_Call{Call: _e.mock.On("Find", ctx, filter, limit, skip)} 133 | } 134 | 135 | func (_c *MockUserMapper_Find_Call) Run(run func(ctx context.Context, filter interface{}, limit int, skip int)) *MockUserMapper_Find_Call { 136 | _c.Call.Run(func(args mock.Arguments) { 137 | run(args[0].(context.Context), args[1].(interface{}), args[2].(int), args[3].(int)) 138 | }) 139 | return _c 140 | } 141 | 142 | func (_c *MockUserMapper_Find_Call) Return(_a0 int64, _a1 models.Users, _a2 error) *MockUserMapper_Find_Call { 143 | _c.Call.Return(_a0, _a1, _a2) 144 | return _c 145 | } 146 | 147 | func (_c *MockUserMapper_Find_Call) RunAndReturn(run func(context.Context, interface{}, int, int) (int64, models.Users, error)) *MockUserMapper_Find_Call { 148 | _c.Call.Return(run) 149 | return _c 150 | } 151 | 152 | // FindOne provides a mock function with given fields: ctx, filter 153 | func (_m *MockUserMapper) FindOne(ctx context.Context, filter interface{}) (*models.User, error) { 154 | ret := _m.Called(ctx, filter) 155 | 156 | if len(ret) == 0 { 157 | panic("no return value specified for FindOne") 158 | } 159 | 160 | var r0 *models.User 161 | var r1 error 162 | if rf, ok := ret.Get(0).(func(context.Context, interface{}) (*models.User, error)); ok { 163 | return rf(ctx, filter) 164 | } 165 | if rf, ok := ret.Get(0).(func(context.Context, interface{}) *models.User); ok { 166 | r0 = rf(ctx, filter) 167 | } else { 168 | if ret.Get(0) != nil { 169 | r0 = ret.Get(0).(*models.User) 170 | } 171 | } 172 | 173 | if rf, ok := ret.Get(1).(func(context.Context, interface{}) error); ok { 174 | r1 = rf(ctx, filter) 175 | } else { 176 | r1 = ret.Error(1) 177 | } 178 | 179 | return r0, r1 180 | } 181 | 182 | // MockUserMapper_FindOne_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindOne' 183 | type MockUserMapper_FindOne_Call struct { 184 | *mock.Call 185 | } 186 | 187 | // FindOne is a helper method to define mock.On call 188 | // - ctx context.Context 189 | // - filter interface{} 190 | func (_e *MockUserMapper_Expecter) FindOne(ctx interface{}, filter interface{}) *MockUserMapper_FindOne_Call { 191 | return &MockUserMapper_FindOne_Call{Call: _e.mock.On("FindOne", ctx, filter)} 192 | } 193 | 194 | func (_c *MockUserMapper_FindOne_Call) Run(run func(ctx context.Context, filter interface{})) *MockUserMapper_FindOne_Call { 195 | _c.Call.Run(func(args mock.Arguments) { 196 | run(args[0].(context.Context), args[1].(interface{})) 197 | }) 198 | return _c 199 | } 200 | 201 | func (_c *MockUserMapper_FindOne_Call) Return(_a0 *models.User, _a1 error) *MockUserMapper_FindOne_Call { 202 | _c.Call.Return(_a0, _a1) 203 | return _c 204 | } 205 | 206 | func (_c *MockUserMapper_FindOne_Call) RunAndReturn(run func(context.Context, interface{}) (*models.User, error)) *MockUserMapper_FindOne_Call { 207 | _c.Call.Return(run) 208 | return _c 209 | } 210 | 211 | // Update provides a mock function with given fields: ctx, model 212 | func (_m *MockUserMapper) Update(ctx context.Context, model *models.User) (*models.User, error) { 213 | ret := _m.Called(ctx, model) 214 | 215 | if len(ret) == 0 { 216 | panic("no return value specified for Update") 217 | } 218 | 219 | var r0 *models.User 220 | var r1 error 221 | if rf, ok := ret.Get(0).(func(context.Context, *models.User) (*models.User, error)); ok { 222 | return rf(ctx, model) 223 | } 224 | if rf, ok := ret.Get(0).(func(context.Context, *models.User) *models.User); ok { 225 | r0 = rf(ctx, model) 226 | } else { 227 | if ret.Get(0) != nil { 228 | r0 = ret.Get(0).(*models.User) 229 | } 230 | } 231 | 232 | if rf, ok := ret.Get(1).(func(context.Context, *models.User) error); ok { 233 | r1 = rf(ctx, model) 234 | } else { 235 | r1 = ret.Error(1) 236 | } 237 | 238 | return r0, r1 239 | } 240 | 241 | // MockUserMapper_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' 242 | type MockUserMapper_Update_Call struct { 243 | *mock.Call 244 | } 245 | 246 | // Update is a helper method to define mock.On call 247 | // - ctx context.Context 248 | // - model *models.User 249 | func (_e *MockUserMapper_Expecter) Update(ctx interface{}, model interface{}) *MockUserMapper_Update_Call { 250 | return &MockUserMapper_Update_Call{Call: _e.mock.On("Update", ctx, model)} 251 | } 252 | 253 | func (_c *MockUserMapper_Update_Call) Run(run func(ctx context.Context, model *models.User)) *MockUserMapper_Update_Call { 254 | _c.Call.Run(func(args mock.Arguments) { 255 | run(args[0].(context.Context), args[1].(*models.User)) 256 | }) 257 | return _c 258 | } 259 | 260 | func (_c *MockUserMapper_Update_Call) Return(_a0 *models.User, _a1 error) *MockUserMapper_Update_Call { 261 | _c.Call.Return(_a0, _a1) 262 | return _c 263 | } 264 | 265 | func (_c *MockUserMapper_Update_Call) RunAndReturn(run func(context.Context, *models.User) (*models.User, error)) *MockUserMapper_Update_Call { 266 | _c.Call.Return(run) 267 | return _c 268 | } 269 | 270 | // NewMockUserMapper creates a new instance of MockUserMapper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 271 | // The first argument is typically a *testing.T value. 272 | func NewMockUserMapper(t interface { 273 | mock.TestingT 274 | Cleanup(func()) 275 | }) *MockUserMapper { 276 | mock := &MockUserMapper{} 277 | mock.Mock.Test(t) 278 | 279 | t.Cleanup(func() { mock.AssertExpectations(t) }) 280 | 281 | return mock 282 | } 283 | -------------------------------------------------------------------------------- /services/personal_access_token.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.mongodb.org/mongo-driver/bson" 8 | 9 | "github.com/alexferl/echo-boilerplate/data" 10 | "github.com/alexferl/echo-boilerplate/models" 11 | ) 12 | 13 | // PersonalAccessTokenMapper defines the datastore handling persisting User documents. 14 | type PersonalAccessTokenMapper interface { 15 | Create(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) 16 | Find(ctx context.Context, filter any) (models.PersonalAccessTokens, error) 17 | FindOne(ctx context.Context, filter any) (*models.PersonalAccessToken, error) 18 | Update(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) 19 | } 20 | 21 | var ErrPersonalAccessTokenNotFound = errors.New("personal access token not found") 22 | 23 | // PersonalAccessToken defines the application service in charge of interacting with Users. 24 | type PersonalAccessToken struct { 25 | mapper PersonalAccessTokenMapper 26 | } 27 | 28 | func NewPersonalAccessToken(mapper PersonalAccessTokenMapper) *PersonalAccessToken { 29 | return &PersonalAccessToken{mapper: mapper} 30 | } 31 | 32 | func (t *PersonalAccessToken) Create(ctx context.Context, model *models.PersonalAccessToken) (*models.PersonalAccessToken, error) { 33 | token, err := t.mapper.Create(ctx, model) 34 | if err != nil { 35 | return nil, NewError(err, Other, "other") 36 | } 37 | 38 | return token, nil 39 | } 40 | 41 | func (t *PersonalAccessToken) Read(ctx context.Context, userId string, id string) (*models.PersonalAccessToken, error) { 42 | filter := bson.D{{"user_id", userId}, {"id", id}} 43 | token, err := t.mapper.FindOne(ctx, filter) 44 | if err != nil { 45 | if errors.Is(err, data.ErrNoDocuments) { 46 | return nil, NewError(err, NotExist, ErrPersonalAccessTokenNotFound.Error()) 47 | } 48 | return nil, NewError(err, Other, "other") 49 | } 50 | 51 | return token, nil 52 | } 53 | 54 | func (t *PersonalAccessToken) Revoke(ctx context.Context, model *models.PersonalAccessToken) error { 55 | model.IsRevoked = true 56 | _, err := t.mapper.Update(ctx, model) 57 | if err != nil { 58 | return NewError(err, Other, "other") 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (t *PersonalAccessToken) Find(ctx context.Context, userId string) (models.PersonalAccessTokens, error) { 65 | filter := bson.D{{"user_id", userId}} 66 | tokens, err := t.mapper.Find(ctx, filter) 67 | if err != nil { 68 | return nil, NewError(err, Other, "other") 69 | } 70 | 71 | return tokens, nil 72 | } 73 | 74 | func (t *PersonalAccessToken) FindOne(ctx context.Context, userId string, name string) (*models.PersonalAccessToken, error) { 75 | filter := bson.D{{"user_id", userId}, {"name", name}} 76 | user, err := t.mapper.FindOne(ctx, filter) 77 | if err != nil { 78 | if errors.Is(err, data.ErrNoDocuments) { 79 | return nil, NewError(err, NotExist, ErrPersonalAccessTokenNotFound.Error()) 80 | } 81 | return nil, NewError(err, Other, "other") 82 | } 83 | 84 | return user, nil 85 | } 86 | -------------------------------------------------------------------------------- /services/personal_access_token_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/mock" 10 | "github.com/stretchr/testify/suite" 11 | 12 | "github.com/alexferl/echo-boilerplate/data" 13 | "github.com/alexferl/echo-boilerplate/models" 14 | "github.com/alexferl/echo-boilerplate/services" 15 | ) 16 | 17 | type PersonalAccessTokenTestSuite struct { 18 | suite.Suite 19 | mapper *services.MockPersonalAccessTokenMapper 20 | svc *services.PersonalAccessToken 21 | user *models.User 22 | } 23 | 24 | func (s *PersonalAccessTokenTestSuite) SetupTest() { 25 | s.mapper = services.NewMockPersonalAccessTokenMapper(s.T()) 26 | s.svc = services.NewPersonalAccessToken(s.mapper) 27 | user := models.NewUser("test@email.com", "test") 28 | user.Id = "100" 29 | s.user = user 30 | } 31 | 32 | func TestPersonalAccessToken(t *testing.T) { 33 | suite.Run(t, new(PersonalAccessTokenTestSuite)) 34 | } 35 | 36 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessToken_Create() { 37 | name := "my_token" 38 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 39 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 40 | s.Assert().NoError(err) 41 | 42 | s.mapper.EXPECT(). 43 | Create(mock.Anything, mock.Anything). 44 | Return(m, nil) 45 | 46 | pat, err := s.svc.Create(context.Background(), m) 47 | s.Assert().NoError(err) 48 | s.Assert().Equal(name, pat.Name) 49 | s.Assert().Equal(expiresAt, pat.ExpiresAt.Format("2006-01-02")) 50 | } 51 | 52 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_Read() { 53 | name := "my_token" 54 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 55 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 56 | s.Assert().NoError(err) 57 | id := "123" 58 | m.Id = id 59 | 60 | s.mapper.EXPECT(). 61 | FindOne(mock.Anything, mock.Anything). 62 | Return(m, nil) 63 | 64 | pat, err := s.svc.Read(context.Background(), s.user.Id, id) 65 | s.Assert().NoError(err) 66 | s.Assert().Equal(id, pat.Id) 67 | s.Assert().Equal(name, pat.Name) 68 | s.Assert().Equal(expiresAt, pat.ExpiresAt.Format("2006-01-02")) 69 | } 70 | 71 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_Read_Err() { 72 | name := "my_token" 73 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 74 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 75 | s.Assert().NoError(err) 76 | id := "123" 77 | m.Id = id 78 | 79 | s.mapper.EXPECT(). 80 | FindOne(mock.Anything, mock.Anything). 81 | Return(nil, data.ErrNoDocuments) 82 | 83 | _, err = s.svc.Read(context.Background(), s.user.Id, id) 84 | s.Assert().Error(err) 85 | var se *services.Error 86 | s.Assert().ErrorAs(err, &se) 87 | if errors.As(err, &se) { 88 | s.Assert().Equal(services.NotExist, se.Kind) 89 | } 90 | } 91 | 92 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_Revoke() { 93 | name := "my_token" 94 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 95 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 96 | s.Assert().NoError(err) 97 | id := "123" 98 | m.Id = id 99 | 100 | s.mapper.EXPECT(). 101 | Update(mock.Anything, mock.Anything). 102 | Return(m, nil) 103 | 104 | err = s.svc.Revoke(context.Background(), m) 105 | s.Assert().NoError(err) 106 | 107 | s.mapper.EXPECT(). 108 | FindOne(mock.Anything, mock.Anything). 109 | Return(m, nil) 110 | 111 | pat, err := s.svc.Read(context.Background(), s.user.Id, id) 112 | s.Assert().NoError(err) 113 | s.Assert().True(pat.IsRevoked) 114 | s.Assert().Equal(id, pat.Id) 115 | s.Assert().Equal(name, pat.Name) 116 | s.Assert().Equal(expiresAt, pat.ExpiresAt.Format("2006-01-02")) 117 | } 118 | 119 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_Find() { 120 | s.mapper.EXPECT(). 121 | Find(mock.Anything, mock.Anything). 122 | Return(models.PersonalAccessTokens{}, nil) 123 | 124 | pats, err := s.svc.Find(context.Background(), "123") 125 | s.Assert().NoError(err) 126 | s.Assert().Equal(models.PersonalAccessTokens{}, pats) 127 | } 128 | 129 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_FindOne() { 130 | name := "my_token" 131 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 132 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 133 | s.Assert().NoError(err) 134 | id := "123" 135 | userId := "456" 136 | m.Id = id 137 | m.UserId = userId 138 | 139 | s.mapper.EXPECT(). 140 | FindOne(mock.Anything, mock.Anything). 141 | Return(m, nil) 142 | 143 | pat, err := s.svc.FindOne(context.Background(), userId, name) 144 | s.Assert().NoError(err) 145 | s.Assert().Equal(id, pat.Id) 146 | s.Assert().Equal(userId, pat.UserId) 147 | s.Assert().Equal(name, pat.Name) 148 | s.Assert().Equal(expiresAt, pat.ExpiresAt.Format("2006-01-02")) 149 | } 150 | 151 | func (s *PersonalAccessTokenTestSuite) TestPersonalAccessTokenTestSuite_FindOne_Err() { 152 | name := "my_token" 153 | expiresAt := time.Now().Add((7 * 24) * time.Hour).Format("2006-01-02") 154 | m, err := models.NewPersonalAccessToken(s.user.Id, name, expiresAt) 155 | s.Assert().NoError(err) 156 | id := "123" 157 | userId := "456" 158 | m.Id = id 159 | m.UserId = userId 160 | 161 | s.mapper.EXPECT(). 162 | FindOne(mock.Anything, mock.Anything). 163 | Return(nil, data.ErrNoDocuments) 164 | 165 | _, err = s.svc.FindOne(context.Background(), id, "") 166 | s.Assert().Error(err) 167 | var se *services.Error 168 | s.Assert().ErrorAs(err, &se) 169 | if errors.As(err, &se) { 170 | s.Assert().Equal(services.NotExist, se.Kind) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /services/setup_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import _ "github.com/alexferl/echo-boilerplate/testing" 4 | -------------------------------------------------------------------------------- /services/task.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "go.mongodb.org/mongo-driver/bson" 9 | 10 | "github.com/alexferl/echo-boilerplate/data" 11 | "github.com/alexferl/echo-boilerplate/models" 12 | ) 13 | 14 | // TaskMapper defines the datastore handling persisting Task documents. 15 | type TaskMapper interface { 16 | Create(ctx context.Context, model *models.Task) (*models.Task, error) 17 | Find(ctx context.Context, filter any, limit int, skip int) (int64, models.Tasks, error) 18 | FindOneById(ctx context.Context, id string) (*models.Task, error) 19 | Update(ctx context.Context, model *models.Task) (*models.Task, error) 20 | } 21 | 22 | var ( 23 | ErrTaskDeleted = errors.New("task was deleted") 24 | ErrTaskNotFound = errors.New("task not found") 25 | ) 26 | 27 | // Task defines the application service in charge of interacting with Tasks. 28 | type Task struct { 29 | mapper TaskMapper 30 | } 31 | 32 | func NewTask(mapper TaskMapper) *Task { 33 | return &Task{mapper: mapper} 34 | } 35 | 36 | func (t *Task) Create(ctx context.Context, id string, model *models.Task) (*models.Task, error) { 37 | model.Create(id) 38 | task, err := t.mapper.Create(ctx, model) 39 | if err != nil { 40 | return nil, NewError(err, Other, "other") 41 | } 42 | 43 | return task, nil 44 | } 45 | 46 | func (t *Task) Read(ctx context.Context, id string) (*models.Task, error) { 47 | task, err := t.mapper.FindOneById(ctx, id) 48 | if err != nil { 49 | if errors.Is(err, data.ErrNoDocuments) { 50 | return nil, NewError(err, NotExist, ErrTaskNotFound.Error()) 51 | } 52 | return nil, NewError(err, Other, "other") 53 | } 54 | 55 | if task.DeletedBy != nil { 56 | return nil, NewError(err, Deleted, ErrTaskDeleted.Error()) 57 | } 58 | 59 | return task, nil 60 | } 61 | 62 | func (t *Task) Update(ctx context.Context, id string, model *models.Task) (*models.Task, error) { 63 | model.Update(id) 64 | task, err := t.mapper.Update(ctx, model) 65 | if err != nil { 66 | return nil, NewError(err, Other, "other") 67 | } 68 | 69 | return task, nil 70 | } 71 | 72 | func (t *Task) Delete(ctx context.Context, id string, model *models.Task) error { 73 | model.Delete(id) 74 | _, err := t.mapper.Update(ctx, model) 75 | if err != nil { 76 | return NewError(err, Other, "other") 77 | } 78 | 79 | return nil 80 | } 81 | 82 | func (t *Task) Find(ctx context.Context, params *models.TaskSearchParams) (int64, models.Tasks, error) { 83 | filter := bson.M{"deleted_at": bson.M{"$eq": nil}} 84 | completed := params.Completed 85 | if len(completed) > 0 { 86 | arr := bson.A{} 87 | for _, i := range completed { 88 | s := strings.ToLower(i) 89 | if s == "true" { 90 | arr = append(arr, true) 91 | } else if s == "false" { 92 | arr = append(arr, false) 93 | } 94 | } 95 | filter["completed"] = bson.M{"$in": arr} 96 | } 97 | createdBy := params.CreatedBy 98 | if createdBy != "" { 99 | filter["created_by"] = createdBy 100 | } 101 | query := params.Queries 102 | if len(query) > 0 { 103 | filter["$text"] = bson.M{"$search": strings.Join(query, " ")} 104 | } 105 | 106 | count, tasks, err := t.mapper.Find(ctx, filter, params.Limit, params.Skip) 107 | if err != nil { 108 | return 0, nil, NewError(err, Other, "other") 109 | } 110 | 111 | return count, tasks, nil 112 | } 113 | -------------------------------------------------------------------------------- /services/task_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/mock" 9 | "github.com/stretchr/testify/suite" 10 | 11 | "github.com/alexferl/echo-boilerplate/data" 12 | "github.com/alexferl/echo-boilerplate/models" 13 | "github.com/alexferl/echo-boilerplate/services" 14 | ) 15 | 16 | type TaskTestSuite struct { 17 | suite.Suite 18 | mapper *services.MockTaskMapper 19 | svc *services.Task 20 | } 21 | 22 | func (s *TaskTestSuite) SetupTest() { 23 | s.mapper = services.NewMockTaskMapper(s.T()) 24 | s.svc = services.NewTask(s.mapper) 25 | } 26 | 27 | func TestTaskTestSuite(t *testing.T) { 28 | suite.Run(t, new(TaskTestSuite)) 29 | } 30 | 31 | func (s *TaskTestSuite) TestTask_Create() { 32 | m := models.NewTask() 33 | id := "123" 34 | m.Create(id) 35 | 36 | s.mapper.EXPECT(). 37 | Create(mock.Anything, mock.Anything). 38 | Return(m, nil) 39 | 40 | task, err := s.svc.Create(context.Background(), id, m) 41 | s.Assert().NoError(err) 42 | s.Assert().NotNil(task.CreatedBy) 43 | } 44 | 45 | func (s *TaskTestSuite) TestTask_Read() { 46 | m := models.NewTask() 47 | id := "123" 48 | m.Id = id 49 | 50 | s.mapper.EXPECT(). 51 | FindOneById(mock.Anything, mock.Anything). 52 | Return(m, nil) 53 | 54 | task, err := s.svc.Read(context.Background(), id) 55 | s.Assert().NoError(err) 56 | s.Assert().Equal(id, task.Id) 57 | } 58 | 59 | func (s *TaskTestSuite) TestTask_Read_Err() { 60 | m := models.NewTask() 61 | id := "123" 62 | m.Id = id 63 | 64 | s.mapper.EXPECT(). 65 | FindOneById(mock.Anything, mock.Anything). 66 | Return(nil, data.ErrNoDocuments) 67 | 68 | _, err := s.svc.Read(context.Background(), id) 69 | s.Assert().Error(err) 70 | var se *services.Error 71 | s.Assert().ErrorAs(err, &se) 72 | if errors.As(err, &se) { 73 | s.Assert().Equal(services.NotExist, se.Kind) 74 | } 75 | } 76 | 77 | func (s *TaskTestSuite) TestTask_Update() { 78 | m := models.NewTask() 79 | id := "123" 80 | m.Update(id) 81 | 82 | s.mapper.EXPECT(). 83 | Update(mock.Anything, mock.Anything). 84 | Return(m, nil) 85 | 86 | task, err := s.svc.Update(context.Background(), id, m) 87 | s.Assert().NoError(err) 88 | s.Assert().NotNil(task.UpdatedBy) 89 | } 90 | 91 | func (s *TaskTestSuite) TestTask_Delete() { 92 | m := models.NewTask() 93 | id := "123" 94 | m.Delete(id) 95 | 96 | s.mapper.EXPECT(). 97 | Update(mock.Anything, mock.Anything). 98 | Return(m, nil) 99 | 100 | err := s.svc.Delete(context.Background(), id, m) 101 | s.Assert().NoError(err) 102 | 103 | s.mapper.EXPECT(). 104 | FindOneById(mock.Anything, mock.Anything). 105 | Return(m, nil) 106 | 107 | _, err = s.svc.Read(context.Background(), id) 108 | s.Assert().Error(err) 109 | var se *services.Error 110 | s.Assert().ErrorAs(err, &se) 111 | if errors.As(err, &se) { 112 | s.Assert().Equal(services.Deleted, se.Kind) 113 | } 114 | } 115 | 116 | func (s *TaskTestSuite) TestTask_Find() { 117 | s.mapper.EXPECT(). 118 | Find(mock.Anything, mock.Anything, 1, 0). 119 | Return(1, models.Tasks{}, nil) 120 | 121 | count, tasks, err := s.svc.Find(context.Background(), &models.TaskSearchParams{ 122 | Completed: []string{"true", "false"}, 123 | CreatedBy: "123", 124 | Queries: []string{"foo", "bar"}, 125 | Limit: 1, 126 | Skip: 0, 127 | }) 128 | s.Assert().NoError(err) 129 | s.Assert().Equal(int64(1), count) 130 | s.Assert().Equal(models.Tasks{}, tasks) 131 | } 132 | -------------------------------------------------------------------------------- /services/user.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/mongo" 9 | 10 | "github.com/alexferl/echo-boilerplate/data" 11 | "github.com/alexferl/echo-boilerplate/models" 12 | ) 13 | 14 | // UserMapper defines the datastore handling persisting User documents. 15 | type UserMapper interface { 16 | Create(ctx context.Context, model *models.User) (*models.User, error) 17 | Find(ctx context.Context, filter any, limit int, skip int) (int64, models.Users, error) 18 | FindOne(ctx context.Context, filter any) (*models.User, error) 19 | Update(ctx context.Context, model *models.User) (*models.User, error) 20 | } 21 | 22 | var ( 23 | ErrUserDeleted = errors.New("user was deleted") 24 | ErrUserExist = errors.New("email or username already in-use") 25 | ErrUserNotFound = errors.New("user not found") 26 | ) 27 | 28 | // User defines the application service in charge of interacting with Users. 29 | type User struct { 30 | mapper UserMapper 31 | } 32 | 33 | func NewUser(mapper UserMapper) *User { 34 | return &User{mapper: mapper} 35 | } 36 | 37 | func (u *User) Create(ctx context.Context, model *models.User) (*models.User, error) { 38 | model.Create(model.Id) 39 | res, err := u.mapper.Create(ctx, model) 40 | if err != nil { 41 | if mongo.IsDuplicateKeyError(err) { 42 | return nil, NewError(err, Exist, ErrUserExist.Error()) 43 | } 44 | return nil, NewError(err, Other, "other") 45 | } 46 | 47 | return res, nil 48 | } 49 | 50 | func (u *User) Read(ctx context.Context, id string) (*models.User, error) { 51 | filter := bson.D{{"$or", bson.A{ 52 | bson.D{{"id", id}}, 53 | bson.D{{"username", id}}, 54 | }}} 55 | user, err := u.mapper.FindOne(ctx, filter) 56 | if err != nil { 57 | if errors.Is(err, data.ErrNoDocuments) { 58 | return nil, NewError(err, NotExist, ErrUserNotFound.Error()) 59 | } 60 | return nil, NewError(err, Other, "other") 61 | } 62 | 63 | if user.DeletedBy != nil { 64 | return nil, NewError(err, Deleted, ErrUserDeleted.Error()) 65 | } 66 | 67 | return user, nil 68 | } 69 | 70 | func (u *User) Update(ctx context.Context, id string, model *models.User) (*models.User, error) { 71 | // some auth updates aren't 'real' updates 72 | // and shouldn't update the UpdateAt timestamp 73 | if id != "" { 74 | model.Update(id) 75 | } 76 | res, err := u.mapper.Update(ctx, model) 77 | if err != nil { 78 | return nil, NewError(err, Other, "other") 79 | } 80 | 81 | return res, nil 82 | } 83 | 84 | func (u *User) Delete(ctx context.Context, id string, model *models.User) error { 85 | model.Delete(id) 86 | _, err := u.mapper.Update(ctx, model) 87 | if err != nil { 88 | return NewError(err, Other, "other") 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (u *User) Find(ctx context.Context, params *models.UserSearchParams) (int64, models.Users, error) { 95 | filter := bson.M{"deleted_at": bson.M{"$eq": nil}} 96 | count, users, err := u.mapper.Find(ctx, filter, params.Limit, params.Skip) 97 | if err != nil { 98 | return 0, nil, NewError(err, Other, "other") 99 | } 100 | 101 | return count, users, nil 102 | } 103 | 104 | func (u *User) FindOneByEmailOrUsername(ctx context.Context, email string, username string) (*models.User, error) { 105 | filter := bson.D{{"$or", bson.A{ 106 | bson.D{{"email", email}}, 107 | bson.D{{"username", username}}, 108 | }}} 109 | user, err := u.mapper.FindOne(ctx, filter) 110 | if err != nil { 111 | if errors.Is(err, data.ErrNoDocuments) { 112 | return nil, NewError(err, NotExist, ErrUserNotFound.Error()) 113 | } 114 | return nil, NewError(err, Other, "other") 115 | } 116 | 117 | return user, nil 118 | } 119 | -------------------------------------------------------------------------------- /services/user_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/mock" 9 | "github.com/stretchr/testify/suite" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | 12 | "github.com/alexferl/echo-boilerplate/data" 13 | "github.com/alexferl/echo-boilerplate/models" 14 | "github.com/alexferl/echo-boilerplate/services" 15 | ) 16 | 17 | type UserTestSuite struct { 18 | suite.Suite 19 | mapper *services.MockUserMapper 20 | svc *services.User 21 | } 22 | 23 | func (s *UserTestSuite) SetupTest() { 24 | s.mapper = services.NewMockUserMapper(s.T()) 25 | s.svc = services.NewUser(s.mapper) 26 | } 27 | 28 | func TestUserTestSuite(t *testing.T) { 29 | suite.Run(t, new(UserTestSuite)) 30 | } 31 | 32 | func (s *UserTestSuite) TestUser_Create() { 33 | email := "test@example.com" 34 | username := "test" 35 | m := models.NewUser(email, username) 36 | id := "123" 37 | m.Create(id) 38 | 39 | s.mapper.EXPECT(). 40 | Create(mock.Anything, mock.Anything). 41 | Return(m, nil) 42 | 43 | user, err := s.svc.Create(context.Background(), m) 44 | s.Assert().NoError(err) 45 | s.Assert().NotNil(user.CreatedBy) 46 | s.Assert().Equal(email, user.Email) 47 | s.Assert().Equal(username, user.Username) 48 | } 49 | 50 | func (s *UserTestSuite) TestUser_Create_Err() { 51 | email := "test@example.com" 52 | username := "test" 53 | m := models.NewUser(email, username) 54 | id := "123" 55 | m.Create(id) 56 | 57 | s.mapper.EXPECT(). 58 | Create(mock.Anything, mock.Anything). 59 | Return(nil, &mongo.WriteError{Code: 11000}) 60 | 61 | _, err := s.svc.Create(context.Background(), m) 62 | s.Assert().Error(err) 63 | var se *services.Error 64 | s.Assert().ErrorAs(err, &se) 65 | if errors.As(err, &se) { 66 | s.Assert().Equal(services.Exist, se.Kind) 67 | } 68 | } 69 | 70 | func (s *UserTestSuite) TestUser_Read() { 71 | email := "test@example.com" 72 | username := "test" 73 | m := models.NewUser(email, username) 74 | id := "123" 75 | m.Id = id 76 | 77 | s.mapper.EXPECT(). 78 | FindOne(mock.Anything, mock.Anything). 79 | Return(m, nil) 80 | 81 | user, err := s.svc.Read(context.Background(), id) 82 | s.Assert().NoError(err) 83 | s.Assert().Equal(id, user.Id) 84 | s.Assert().Equal(email, user.Email) 85 | s.Assert().Equal(username, user.Username) 86 | } 87 | 88 | func (s *UserTestSuite) TestUser_Read_Err() { 89 | email := "test@example.com" 90 | username := "test" 91 | m := models.NewUser(email, username) 92 | id := "123" 93 | m.Id = id 94 | 95 | s.mapper.EXPECT(). 96 | FindOne(mock.Anything, mock.Anything). 97 | Return(nil, data.ErrNoDocuments) 98 | 99 | _, err := s.svc.Read(context.Background(), id) 100 | s.Assert().Error(err) 101 | var se *services.Error 102 | s.Assert().ErrorAs(err, &se) 103 | if errors.As(err, &se) { 104 | s.Assert().Equal(services.NotExist, se.Kind) 105 | } 106 | } 107 | 108 | func (s *UserTestSuite) TestUser_Update() { 109 | email := "test@example.com" 110 | username := "test" 111 | m := models.NewUser(email, username) 112 | id := "123" 113 | m.Update(id) 114 | 115 | s.mapper.EXPECT(). 116 | Update(mock.Anything, mock.Anything). 117 | Return(m, nil) 118 | 119 | task, err := s.svc.Update(context.Background(), id, m) 120 | s.Assert().NoError(err) 121 | s.Assert().NotNil(task.UpdatedBy) 122 | } 123 | 124 | func (s *UserTestSuite) TestUser_Delete() { 125 | email := "test@example.com" 126 | username := "test" 127 | m := models.NewUser(email, username) 128 | id := "123" 129 | m.Delete(id) 130 | 131 | s.mapper.EXPECT(). 132 | Update(mock.Anything, mock.Anything). 133 | Return(m, nil) 134 | 135 | err := s.svc.Delete(context.Background(), id, m) 136 | s.Assert().NoError(err) 137 | 138 | s.mapper.EXPECT(). 139 | FindOne(mock.Anything, mock.Anything). 140 | Return(m, nil) 141 | 142 | _, err = s.svc.Read(context.Background(), id) 143 | s.Assert().Error(err) 144 | var se *services.Error 145 | s.Assert().ErrorAs(err, &se) 146 | if errors.As(err, &se) { 147 | s.Assert().Equal(services.Deleted, se.Kind) 148 | } 149 | } 150 | 151 | func (s *UserTestSuite) TestUser_Find() { 152 | s.mapper.EXPECT(). 153 | Find(mock.Anything, mock.Anything, 1, 0). 154 | Return(1, models.Users{}, nil) 155 | 156 | count, tasks, err := s.svc.Find(context.Background(), &models.UserSearchParams{ 157 | Limit: 1, 158 | Skip: 0, 159 | }) 160 | s.Assert().NoError(err) 161 | s.Assert().Equal(int64(1), count) 162 | s.Assert().Equal(models.Users{}, tasks) 163 | } 164 | 165 | func (s *UserTestSuite) TestUser_FindOneByEmailOrUsername() { 166 | email := "test@example.com" 167 | username := "test" 168 | m := models.NewUser(email, username) 169 | id := "123" 170 | m.Id = id 171 | 172 | s.mapper.EXPECT(). 173 | FindOne(mock.Anything, mock.Anything). 174 | Return(m, nil) 175 | 176 | user, err := s.svc.FindOneByEmailOrUsername(context.Background(), email, username) 177 | s.Assert().NoError(err) 178 | s.Assert().Equal(id, user.Id) 179 | s.Assert().Equal(email, user.Email) 180 | s.Assert().Equal(username, user.Username) 181 | } 182 | 183 | func (s *UserTestSuite) TestUser_FindOneByEmailOrUsername_Err() { 184 | email := "test@example.com" 185 | username := "test" 186 | m := models.NewUser(email, username) 187 | id := "123" 188 | m.Id = id 189 | 190 | s.mapper.EXPECT(). 191 | FindOne(mock.Anything, mock.Anything). 192 | Return(nil, data.ErrNoDocuments) 193 | 194 | _, err := s.svc.FindOneByEmailOrUsername(context.Background(), email, username) 195 | s.Assert().Error(err) 196 | var se *services.Error 197 | s.Assert().ErrorAs(err, &se) 198 | if errors.As(err, &se) { 199 | s.Assert().Equal(services.NotExist, se.Kind) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /testing/testing.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "runtime" 7 | 8 | "github.com/alexferl/echo-boilerplate/config" 9 | ) 10 | 11 | // to correctly load config files, keys etc. 12 | func init() { 13 | _, filename, _, _ := runtime.Caller(0) 14 | dir := path.Join(path.Dir(filename), "..") 15 | err := os.Chdir(dir) 16 | if err != nil { 17 | panic(err) 18 | } 19 | 20 | c := config.New() 21 | c.BindFlags() 22 | } 23 | -------------------------------------------------------------------------------- /util/bson/bson.go: -------------------------------------------------------------------------------- 1 | package bson 2 | 3 | import ( 4 | "go.mongodb.org/mongo-driver/bson" 5 | "go.mongodb.org/mongo-driver/bson/primitive" 6 | ) 7 | 8 | func DocToStruct(d primitive.D, result any) error { 9 | b, err := bson.Marshal(d) 10 | if err != nil { 11 | return err 12 | } 13 | err = bson.Unmarshal(b, result) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /util/cookie/cookie.go: -------------------------------------------------------------------------------- 1 | package cookie 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/alexferl/echo-boilerplate/config" 11 | "github.com/alexferl/echo-boilerplate/util/hash" 12 | ) 13 | 14 | type Options struct { 15 | Name string 16 | Value string 17 | Path string 18 | Domain string 19 | SameSite http.SameSite 20 | HttpOnly bool 21 | MaxAge int 22 | } 23 | 24 | func New(opts *Options) *http.Cookie { 25 | return &http.Cookie{ 26 | Name: opts.Name, 27 | Value: opts.Value, 28 | Path: opts.Path, 29 | Domain: opts.Domain, 30 | SameSite: opts.SameSite, 31 | HttpOnly: opts.HttpOnly, 32 | Secure: !(strings.ToUpper(viper.GetString(config.EnvName)) == "LOCAL"), 33 | MaxAge: opts.MaxAge, 34 | } 35 | } 36 | 37 | func NewAccessToken(access []byte) *http.Cookie { 38 | opts := &Options{ 39 | Name: viper.GetString(config.JWTAccessTokenCookieName), 40 | Value: string(access), 41 | Path: "/", 42 | Domain: viper.GetString(config.CookiesDomain), 43 | SameSite: http.SameSiteStrictMode, 44 | MaxAge: int(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), 45 | } 46 | 47 | return New(opts) 48 | } 49 | 50 | func NewRefreshToken(refresh []byte) *http.Cookie { 51 | opts := &Options{ 52 | Name: viper.GetString(config.JWTRefreshTokenCookieName), 53 | Value: string(refresh), 54 | Path: "/auth", 55 | Domain: viper.GetString(config.CookiesDomain), 56 | SameSite: http.SameSiteStrictMode, 57 | HttpOnly: true, 58 | MaxAge: int(viper.GetDuration(config.JWTRefreshTokenExpiry).Seconds()), 59 | } 60 | 61 | return New(opts) 62 | } 63 | 64 | func NewCSRF(access []byte) *http.Cookie { 65 | opts := &Options{ 66 | Name: viper.GetString(config.CSRFCookieName), 67 | Value: string(access), 68 | Path: "/", 69 | Domain: viper.GetString(config.CSRFCookieDomain), 70 | SameSite: http.SameSiteStrictMode, 71 | MaxAge: int(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), 72 | } 73 | 74 | return New(opts) 75 | } 76 | 77 | func SetToken(c echo.Context, access []byte, refresh []byte) { 78 | c.SetCookie(NewAccessToken(access)) 79 | c.SetCookie(NewRefreshToken(refresh)) 80 | 81 | if viper.GetBool(config.CSRFEnabled) { 82 | s := hash.NewHMAC(access, []byte(viper.GetString(config.CSRFSecretKey))) 83 | c.SetCookie(NewCSRF([]byte(s))) 84 | } 85 | } 86 | 87 | func SetExpiredToken(c echo.Context) { 88 | accessOpts := &Options{ 89 | Name: viper.GetString(config.JWTAccessTokenCookieName), 90 | Value: "", 91 | Path: "/", 92 | Domain: viper.GetString(config.CookiesDomain), 93 | SameSite: http.SameSiteStrictMode, 94 | MaxAge: -1, 95 | } 96 | 97 | refreshOpts := &Options{ 98 | Name: viper.GetString(config.JWTRefreshTokenCookieName), 99 | Value: "", 100 | Path: "/auth", 101 | Domain: viper.GetString(config.CookiesDomain), 102 | SameSite: http.SameSiteStrictMode, 103 | HttpOnly: true, 104 | MaxAge: -1, 105 | } 106 | 107 | c.SetCookie(New(accessOpts)) 108 | c.SetCookie(New(refreshOpts)) 109 | 110 | if viper.GetBool(config.CSRFEnabled) { 111 | csrfOpts := &Options{ 112 | Name: viper.GetString(config.CSRFCookieName), 113 | Value: "", 114 | Path: "/", 115 | Domain: viper.GetString(config.CSRFCookieDomain), 116 | SameSite: http.SameSiteStrictMode, 117 | MaxAge: -1, 118 | } 119 | c.SetCookie(New(csrfOpts)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /util/cookie/cookie_test.go: -------------------------------------------------------------------------------- 1 | package cookie 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/spf13/viper" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/alexferl/echo-boilerplate/config" 13 | "github.com/alexferl/echo-boilerplate/util/jwt" 14 | ) 15 | 16 | func TestNew(t *testing.T) { 17 | opts := &Options{ 18 | Name: "name", 19 | Value: "value", 20 | Path: "/path", 21 | SameSite: http.SameSiteStrictMode, 22 | HttpOnly: true, 23 | MaxAge: 10, 24 | } 25 | c := New(opts) 26 | 27 | assert.Equal(t, opts.Name, c.Name) 28 | assert.Equal(t, opts.Value, c.Value) 29 | assert.Equal(t, opts.Path, c.Path) 30 | assert.Equal(t, opts.SameSite, c.SameSite) 31 | assert.Equal(t, opts.HttpOnly, c.HttpOnly) 32 | assert.Equal(t, opts.MaxAge, c.MaxAge) 33 | } 34 | 35 | func TestNewAccessToken(t *testing.T) { 36 | value := "access" 37 | cookie := NewAccessToken([]byte(value)) 38 | 39 | assert.Equal(t, viper.GetString(config.JWTAccessTokenCookieName), cookie.Name) 40 | assert.Equal(t, value, cookie.Value) 41 | assert.Equal(t, "/", cookie.Path) 42 | assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) 43 | assert.Equal(t, int(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), cookie.MaxAge) 44 | } 45 | 46 | func TestNewRefreshToken(t *testing.T) { 47 | value := "refresh" 48 | cookie := NewRefreshToken([]byte(value)) 49 | 50 | assert.Equal(t, viper.GetString(config.JWTRefreshTokenCookieName), cookie.Name) 51 | assert.Equal(t, value, cookie.Value) 52 | assert.Equal(t, "/auth", cookie.Path) 53 | assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) 54 | assert.Equal(t, int(viper.GetDuration(config.JWTRefreshTokenExpiry).Seconds()), cookie.MaxAge) 55 | } 56 | 57 | func TestNewCSRF(t *testing.T) { 58 | value := "csrf" 59 | cookie := NewCSRF([]byte(value)) 60 | 61 | assert.Equal(t, viper.GetString(config.CSRFCookieName), cookie.Name) 62 | assert.Equal(t, value, cookie.Value) 63 | assert.Equal(t, "/", cookie.Path) 64 | assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) 65 | assert.Equal(t, int(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), cookie.MaxAge) 66 | } 67 | 68 | func TestSetToken(t *testing.T) { 69 | req := httptest.NewRequest(http.MethodGet, "/", nil) 70 | resp := httptest.NewRecorder() 71 | ctx := echo.New().NewContext(req, resp) 72 | 73 | access, refresh, err := jwt.GenerateTokens("123", nil) 74 | assert.NoError(t, err) 75 | 76 | SetToken(ctx, access, refresh) 77 | 78 | accessCookie := resp.Result().Cookies()[0] 79 | refreshCookie := resp.Result().Cookies()[1] 80 | 81 | assert.Equal(t, string(access), accessCookie.Value) 82 | assert.Equal(t, int(viper.GetDuration(config.JWTAccessTokenExpiry).Seconds()), accessCookie.MaxAge) 83 | 84 | assert.Equal(t, string(refresh), refreshCookie.Value) 85 | assert.Equal(t, int(viper.GetDuration(config.JWTRefreshTokenExpiry).Seconds()), refreshCookie.MaxAge) 86 | } 87 | 88 | func TestSetExpiredToken(t *testing.T) { 89 | req := httptest.NewRequest(http.MethodGet, "/", nil) 90 | resp := httptest.NewRecorder() 91 | ctx := echo.New().NewContext(req, resp) 92 | 93 | SetExpiredToken(ctx) 94 | 95 | accessCookie := resp.Result().Cookies()[0] 96 | refreshCookie := resp.Result().Cookies()[1] 97 | 98 | assert.Equal(t, "", accessCookie.Value) 99 | assert.Equal(t, -1, accessCookie.MaxAge) 100 | 101 | assert.Equal(t, "", refreshCookie.Value) 102 | assert.Equal(t, -1, refreshCookie.MaxAge) 103 | } 104 | -------------------------------------------------------------------------------- /util/cookie/setup_test.go: -------------------------------------------------------------------------------- 1 | package cookie 2 | 3 | import _ "github.com/alexferl/echo-boilerplate/testing" 4 | -------------------------------------------------------------------------------- /util/hash/hash.go: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | ) 8 | 9 | func NewHMAC(message []byte, key []byte) string { 10 | mac := hmac.New(sha256.New, key) 11 | mac.Write(message) 12 | return hex.EncodeToString(mac.Sum(nil)) 13 | } 14 | 15 | // ValidMAC reports whether messageMAC is a valid HMAC tag for message. 16 | func ValidMAC(message []byte, messageMAC []byte, key []byte) bool { 17 | expectedMAC := NewHMAC(message, key) 18 | return hmac.Equal(messageMAC, []byte(expectedMAC)) 19 | } 20 | -------------------------------------------------------------------------------- /util/hash/hash_test.go: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHash(t *testing.T) { 10 | msg := "my message" 11 | key := "s3cret" 12 | h := NewHMAC([]byte(msg), []byte(key)) 13 | b := ValidMAC([]byte(msg), []byte(h), []byte(key)) 14 | 15 | assert.True(t, b) 16 | } 17 | -------------------------------------------------------------------------------- /util/jwt/jwt.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "fmt" 8 | "io" 9 | "os" 10 | "time" 11 | 12 | "github.com/lestrrat-go/jwx/v2/jwa" 13 | jwx "github.com/lestrrat-go/jwx/v2/jwt" 14 | "github.com/spf13/viper" 15 | 16 | "github.com/alexferl/echo-boilerplate/config" 17 | ) 18 | 19 | var PrivateKey *rsa.PrivateKey = nil 20 | 21 | func init() { 22 | c := config.New() 23 | c.BindFlags() 24 | 25 | key, err := loadPrivateKey() 26 | if err != nil { 27 | panic(err) 28 | } 29 | PrivateKey = key 30 | } 31 | 32 | type Type int8 33 | 34 | const ( 35 | AccessToken Type = iota + 1 36 | RefreshToken 37 | PersonalToken 38 | ) 39 | 40 | func (t Type) String() string { 41 | return [...]string{"access", "refresh", "personal"}[t-1] 42 | } 43 | 44 | func GenerateTokens(sub string, claims map[string]any) ([]byte, []byte, error) { 45 | access, err := GenerateAccessToken(sub, claims) 46 | if err != nil { 47 | return nil, nil, err 48 | } 49 | 50 | refresh, err := GenerateRefreshToken(sub) 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | 55 | return access, refresh, nil 56 | } 57 | 58 | func GenerateAccessToken(sub string, claims map[string]any) ([]byte, error) { 59 | expiry := viper.GetDuration(config.JWTAccessTokenExpiry) 60 | return generateToken(AccessToken, expiry, sub, claims) 61 | } 62 | 63 | func GenerateRefreshToken(sub string) ([]byte, error) { 64 | expiry := viper.GetDuration(config.JWTRefreshTokenExpiry) 65 | return generateToken(RefreshToken, expiry, sub, map[string]any{}) 66 | } 67 | 68 | func GeneratePersonalToken(sub string, expiry time.Duration, claims map[string]any) ([]byte, error) { 69 | return generateToken(PersonalToken, expiry, sub, claims) 70 | } 71 | 72 | func generateToken(typ Type, expiry time.Duration, sub string, claims map[string]any) ([]byte, error) { 73 | builder := jwx.NewBuilder(). 74 | Subject(sub). 75 | Issuer(viper.GetString(config.JWTIssuer)). 76 | IssuedAt(time.Now()). 77 | NotBefore(time.Now()). 78 | Expiration(time.Now().Add(expiry)). 79 | Claim("type", typ.String()) 80 | 81 | if claims != nil { 82 | for k, v := range claims { 83 | builder.Claim(k, v) 84 | } 85 | } 86 | 87 | token, err := builder.Build() 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to build %s token: %v\n", typ.String(), err) 90 | } 91 | 92 | signed, err := jwx.Sign(token, jwx.WithKey(jwa.RS256, PrivateKey)) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to sign %s token: %v\n", typ.String(), err) 95 | } 96 | 97 | return signed, nil 98 | } 99 | 100 | func ParseEncoded(encodedToken []byte) (jwx.Token, error) { 101 | token, err := jwx.Parse(encodedToken, jwx.WithValidate(true), jwx.WithKey(jwa.RS256, PrivateKey)) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | return token, nil 107 | } 108 | 109 | func loadPrivateKey() (*rsa.PrivateKey, error) { 110 | f, err := os.Open(viper.GetString(config.JWTPrivateKey)) 111 | if err != nil { 112 | return nil, fmt.Errorf("failed to open private key: %v", err) 113 | } 114 | 115 | b, err := io.ReadAll(f) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to read private key: %v", err) 118 | } 119 | 120 | block, _ := pem.Decode(b) 121 | if block == nil { 122 | return nil, fmt.Errorf("failed to parse PEM block: %v", err) 123 | } 124 | 125 | var key *rsa.PrivateKey 126 | switch block.Type { 127 | case "RSA PRIVATE KEY": // PKCS#1 128 | key, err = x509.ParsePKCS1PrivateKey(block.Bytes) 129 | if err != nil { 130 | return nil, err 131 | } 132 | case "PRIVATE KEY": // PKCS#8 133 | privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | key = privateKey.(*rsa.PrivateKey) 139 | } 140 | 141 | return key, nil 142 | } 143 | -------------------------------------------------------------------------------- /util/jwt/jwt_test.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/alexferl/echo-boilerplate/config" 9 | ) 10 | 11 | func TestGenerateTokens(t *testing.T) { 12 | c := config.New() 13 | c.BindFlags() 14 | 15 | _, _, err := GenerateTokens("123", nil) 16 | assert.NoError(t, err) 17 | } 18 | 19 | func TestParseEncoded(t *testing.T) { 20 | c := config.New() 21 | c.BindFlags() 22 | 23 | sub := "123" 24 | claim := "mine" 25 | 26 | access, refresh, err := GenerateTokens(sub, map[string]any{"claim": claim}) 27 | assert.NoError(t, err) 28 | 29 | accessToken, err := ParseEncoded(access) 30 | assert.NoError(t, err) 31 | assert.Equal(t, sub, accessToken.Subject()) 32 | accessClaim, ok := accessToken.Get("claim") 33 | assert.True(t, ok) 34 | assert.Equal(t, claim, accessClaim) 35 | 36 | refreshToken, err := ParseEncoded(refresh) 37 | assert.NoError(t, err) 38 | assert.Equal(t, sub, refreshToken.Subject()) 39 | _, ok = refreshToken.Get("claim") 40 | assert.False(t, ok) 41 | } 42 | -------------------------------------------------------------------------------- /util/jwt/setup_test.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import _ "github.com/alexferl/echo-boilerplate/testing" 4 | -------------------------------------------------------------------------------- /util/pagination/pagination.go: -------------------------------------------------------------------------------- 1 | package pagination 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/alexferl/httplink" 11 | "github.com/labstack/echo/v4" 12 | "github.com/spf13/viper" 13 | 14 | "github.com/alexferl/echo-boilerplate/config" 15 | ) 16 | 17 | func ParseParams(c echo.Context) (int, int, int, int) { 18 | var page int 19 | pageQuery := c.QueryParam("page") 20 | page, _ = strconv.Atoi(pageQuery) 21 | 22 | var perPage int 23 | perPageQuery := c.QueryParam("per_page") 24 | perPage, _ = strconv.Atoi(perPageQuery) 25 | 26 | limit := perPage 27 | skip := 0 28 | if page > 1 { 29 | skip = (page * perPage) - perPage 30 | } 31 | 32 | return page, perPage, limit, skip 33 | } 34 | 35 | func SetHeaders(req *http.Request, header http.Header, count int, page int, perPage int) { 36 | prefix := "http" 37 | if strings.HasPrefix(viper.GetString(config.BaseURL), "https") { 38 | prefix = "https" 39 | } 40 | url := fmt.Sprintf("%s://%s%s", prefix, req.Host, req.URL.Path) 41 | 42 | totalPages := int(math.Ceil(float64(count) / float64(perPage))) 43 | lastPage := totalPages 44 | curPage := page 45 | prevPage := 0 46 | if curPage >= 2 { 47 | prevPage = curPage - 1 48 | } 49 | 50 | nextPage := 0 51 | if (curPage + 1) <= totalPages { 52 | nextPage = curPage + 1 53 | } 54 | 55 | header.Set("X-Page", strconv.Itoa(curPage)) 56 | header.Set("X-Per-Page", strconv.Itoa(perPage)) 57 | header.Set("X-Total", strconv.Itoa(count)) 58 | header.Set("X-Total-Pages", strconv.Itoa(totalPages)) 59 | 60 | if nextPage > 0 { 61 | header.Set("X-Next-Page", strconv.Itoa(nextPage)) 62 | httplink.Append(header, formatURL(url, perPage, nextPage), "next") 63 | } 64 | 65 | httplink.Append(header, formatURL(url, perPage, lastPage), "last") 66 | httplink.Append(header, formatURL(url, perPage, 1), "first") 67 | 68 | if prevPage > 0 { 69 | header.Set("X-Prev-Page", strconv.Itoa(prevPage)) 70 | httplink.Append(header, formatURL(url, perPage, prevPage), "prev") 71 | } 72 | } 73 | 74 | func formatURL(uri string, perPage int, page int) string { 75 | return fmt.Sprintf("%s?per_page=%d&page=%d", uri, perPage, page) 76 | } 77 | -------------------------------------------------------------------------------- /util/pagination/pagination_test.go: -------------------------------------------------------------------------------- 1 | package pagination 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strconv" 8 | "testing" 9 | 10 | "github.com/labstack/echo/v4" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestParseParams(t *testing.T) { 16 | c := echo.New() 17 | c.GET("/", func(c echo.Context) error { 18 | page, perPage, limit, skip := ParseParams(c) 19 | 20 | assert.Equal(t, 10, page) 21 | assert.Equal(t, 5, perPage) 22 | assert.Equal(t, 5, limit) 23 | assert.Equal(t, 45, skip) 24 | 25 | return c.NoContent(http.StatusOK) 26 | }) 27 | 28 | req := httptest.NewRequest(http.MethodGet, "/?page=10&per_page=5", nil) 29 | resp := httptest.NewRecorder() 30 | 31 | c.ServeHTTP(resp, req) 32 | } 33 | 34 | func TestSetHeaders(t *testing.T) { 35 | testCases := []struct { 36 | query string 37 | link string 38 | xPage string 39 | xPerPage string 40 | xTotal string 41 | xTotalPages string 42 | xNextPage string 43 | xPrevPage string 44 | }{ 45 | { 46 | query: "", 47 | link: `; rel=last, ` + 48 | `; rel=first`, 49 | xPage: "1", 50 | xPerPage: "10", 51 | xTotal: "10", 52 | xTotalPages: "1", 53 | xNextPage: "", 54 | xPrevPage: "", 55 | }, 56 | { 57 | query: "per_page=10&page=1", 58 | link: `; rel=last, ` + 59 | `; rel=first`, 60 | xPage: "1", 61 | xPerPage: "10", 62 | xTotal: "10", 63 | xTotalPages: "1", 64 | xNextPage: "", 65 | xPrevPage: "", 66 | }, 67 | { 68 | query: "per_page=1&page=2", 69 | link: `; rel=next, ` + 70 | `; rel=last, ` + 71 | `; rel=first, ` + 72 | `; rel=prev`, 73 | xPage: "2", 74 | xPerPage: "1", 75 | xTotal: "10", 76 | xTotalPages: "10", 77 | xNextPage: "3", 78 | xPrevPage: "1", 79 | }, 80 | { 81 | query: "per_page=1&page=10", 82 | link: `; rel=last, ` + 83 | `; rel=first, ` + 84 | `; rel=prev`, 85 | xPage: "10", 86 | xPerPage: "1", 87 | xTotal: "10", 88 | xTotalPages: "10", 89 | xNextPage: "", 90 | xPrevPage: "9", 91 | }, 92 | } 93 | 94 | for _, tc := range testCases { 95 | t.Run(tc.query, func(t *testing.T) { 96 | resp := httptest.NewRecorder() 97 | 98 | total, _ := strconv.Atoi(tc.xTotal) 99 | page, _ := strconv.Atoi(tc.xPage) 100 | perPage, _ := strconv.Atoi(tc.xPerPage) 101 | 102 | req := &http.Request{ 103 | URL: &url.URL{Path: "/users"}, 104 | Host: "example.com", 105 | } 106 | SetHeaders(req, resp.Header(), total, page, perPage) 107 | 108 | h := resp.Header() 109 | 110 | assert.Equal(t, tc.xPage, h.Get("X-Page")) 111 | assert.Equal(t, tc.xPerPage, h.Get("X-Per-Page")) 112 | assert.Equal(t, tc.xTotal, h.Get("X-Total")) 113 | assert.Equal(t, tc.xTotalPages, h.Get("X-Total-Pages")) 114 | assert.Equal(t, tc.xNextPage, h.Get("X-Next-Page")) 115 | assert.Equal(t, tc.xPrevPage, h.Get("X-Prev-Page")) 116 | assert.Equal(t, tc.link, h.Get("Link")) 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /util/password/password.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/matthewhartstonge/argon2" 7 | ) 8 | 9 | func Hash(password []byte) (string, error) { 10 | argon := argon2.DefaultConfig() 11 | encoded, err := argon.HashEncoded(password) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return string(encoded), nil 17 | } 18 | 19 | func Verify(password []byte, encoded []byte) error { 20 | b, err := argon2.VerifyEncoded(encoded, password) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if !b { 26 | return errors.New("mismatch") 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /util/password/password_test.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPassword(t *testing.T) { 10 | pwd := "s3cret" 11 | enc, err := Hash([]byte(pwd)) 12 | assert.NoError(t, err) 13 | 14 | err = Verify([]byte(enc), []byte(pwd)) 15 | assert.NoError(t, err) 16 | } 17 | 18 | func TestWrongPassword(t *testing.T) { 19 | pwd := "s3cret" 20 | enc, err := Hash([]byte(pwd)) 21 | assert.NoError(t, err) 22 | 23 | err = Verify([]byte(enc), []byte("wrong")) 24 | assert.Error(t, err) 25 | } 26 | -------------------------------------------------------------------------------- /util/rand/rand.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | func GenerateRandomBytes(n int) ([]byte, error) { 9 | b := make([]byte, n) 10 | _, err := rand.Read(b) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | return b, nil 16 | } 17 | 18 | func GenerateRandomString(s int) (string, error) { 19 | b, err := GenerateRandomBytes(s) 20 | return base64.URLEncoding.EncodeToString(b), err 21 | } 22 | -------------------------------------------------------------------------------- /util/rand/rand_test.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGenerateRandomString(t *testing.T) { 10 | s, err := GenerateRandomString(10) 11 | 12 | assert.NoError(t, err) 13 | assert.Equal(t, 16, len(s)) 14 | } 15 | --------------------------------------------------------------------------------