├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── LICENSE.md ├── README.md ├── cmd └── server │ └── main.go ├── config ├── config-docker.yaml └── config.yaml ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── config │ └── config.go ├── core │ ├── app │ │ └── app.go │ ├── config │ │ └── config.go │ ├── drivers │ │ └── psql │ │ │ ├── migrations.go │ │ │ └── psql.go │ ├── errors │ │ └── errors.go │ ├── listeners │ │ └── http │ │ │ ├── http.go │ │ │ ├── logging.go │ │ │ └── tracing.go │ ├── logging │ │ └── logging.go │ └── tracing │ │ └── tracing.go ├── events │ ├── events.go │ └── model.go ├── transport │ └── http │ │ ├── errors.go │ │ ├── http.go │ │ ├── http_integration_test.go │ │ ├── mocks │ │ └── http_mock.go │ │ ├── user_integration_test.go │ │ ├── users.go │ │ └── users_exports_test.go └── users │ ├── mocks │ └── users_mock.go │ ├── model │ └── model.go │ ├── store │ ├── findusers.go │ ├── findusers_integration_test.go │ ├── store.go │ ├── store_exports_test.go │ └── store_integration_test.go │ ├── users.go │ └── users_test.go ├── migrations ├── 1_create_users_table.down.sql └── 1_create_users_table.up.sql ├── openapi └── openapi.yaml └── postman ├── Bootstrap Users.postman_collection.json └── Users.postman_collection.json /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | env: 9 | GOPRIVATE: "github.com/speakeasy-api" 10 | jobs: 11 | golangci: 12 | name: lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/setup-go@v3 16 | with: 17 | go-version: 1.18 18 | 19 | - uses: actions/checkout@v3 20 | - name: golangci-lint 21 | uses: golangci/golangci-lint-action@v3 22 | with: 23 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 24 | version: latest 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | env: 9 | GOPRIVATE: "github.com/speakeasy-api" 10 | jobs: 11 | tests: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | go-version: [1.18.x] 18 | 19 | name: Tests - Go ${{ matrix.go-version }} 20 | 21 | steps: 22 | - name: Checkout the code 23 | uses: actions/checkout@v2 24 | 25 | - name: Install Go 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | 30 | - name: Run the tests 31 | run: go test ./... 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - lll 5 | - varnamelen 6 | - exhaustivestruct 7 | - exhaustruct 8 | - godox 9 | - nlreturn 10 | - wsl 11 | - wrapcheck 12 | - gochecknoglobals 13 | - paralleltest 14 | - dupl 15 | - containedctx 16 | # Deprecated 17 | - golint 18 | - maligned 19 | - interfacer 20 | - scopelint 21 | issues: 22 | exclude: 23 | - "returns unexported type" 24 | - "unlambda" 25 | - "should rewrite http.NewRequestWithContext" 26 | exclude-rules: 27 | # Exclude some linters from running on tests files. 28 | - path: _test\.go 29 | linters: 30 | - scopelint 31 | - goerr113 32 | - funlen 33 | - gocognit 34 | - cyclop 35 | - path: _exports_test\.go 36 | linters: 37 | - testpackage 38 | include: 39 | - EXC0012 40 | - EXC0013 41 | - EXC0014 42 | - EXC0015 43 | run: 44 | go: "1.18" 45 | linters-settings: 46 | tagliatelle: 47 | case: 48 | use-field-name: true 49 | rules: 50 | json: snake 51 | yaml: camel 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Build a golang image based on https://docs.docker.com/language/golang/build-images 4 | 5 | FROM golang:1.18-alpine AS build 6 | 7 | WORKDIR /app 8 | 9 | COPY go.mod ./ 10 | COPY go.sum ./ 11 | 12 | RUN go mod download 13 | 14 | COPY ./cmd/server/main.go ./cmd/server/main.go 15 | COPY ./internal/ ./internal/ 16 | 17 | RUN go build -o ./server ./cmd/server/main.go 18 | 19 | # Build the server image 20 | 21 | FROM alpine:latest 22 | 23 | RUN apk --no-cache add ca-certificates 24 | 25 | WORKDIR /root/ 26 | 27 | COPY --from=0 /app/server ./ 28 | COPY ./config/ ./config/ 29 | COPY ./migrations/ ./migrations/ 30 | 31 | EXPOSE 8080 32 | 33 | CMD ["./server"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | Speakeasy 7 | 8 | 9 |

Speakeasy

10 |

Build APIs your users love ❤️ with Speakeasy

11 |
12 | Docs Quickstart  //  Join us on Slack 13 |
14 |
15 | 16 |
17 | 18 |
19 | 20 | # The RESTful API Template Project [Golang] 21 | 22 | ## How To Use This Repo 23 | 24 | This repo is intended to be used by Golang developers seeking to understand the building blocks of a simple and well-constructed REST API service. We have built a simple CRUD API which exhibits the characteristics we expect our own developers to apply to the APIs we build at Speakeasy: 25 | 26 | - **Entity-based**: The resources available should represent the domain model. Each resource should have the CRUD methods implemented (even if not all available to API consumers). In our template, we have a single resource defined (users.go). However other resources could be easily added by copying the template and changing the logic of the service layer. 27 | - **Properly Abstracted**: The Transport, service, and data layers are all cleanly abstracted from one another. This makes it easy to make apply updates to the API endpoints 28 | - **Consistent**: It's important that consumers of a service have guaranteed consistency across the entire range of API endpoints and methods. In this service, responses are consistently formatted whether successfully returning a JSON object or responding with an error code. All the service's methods use shared response (http.go) and error (errors.go) handler functions to ensure consistency. 29 | - **Tested**: We believe that a blend of unit and integration testing is important for ensuring that the service maintains its contract with consumers. The service repo therefore contains a collection of unit and integration tests for the various layers of the service. 30 | - **Explorable**: It is important for developers to be able to play with an endpoint in order to understand it. We have provided Postman collections for testing out the REST endpoints exposed by the service. That's why there is a `Bootstrap Users` collection that can be run using the `Run collection` tool in Postman that will create 100 users to test the search endpoint with. 31 | 32 | This repo can serve as an educational tool, or be used as a foundation upon which developers can build their own basic API scaffolding to turn API development into a consistent and marignally easier activity. 33 | 34 | ## Getting Started 35 | 36 | ### Prerequisites 37 | 38 | - Go 1.18 (should still be backwards compatible with earlier versions) 39 | 40 | ### Running locally 41 | 42 | 1. From root of the repo 43 | 2. Run `docker-compose up` will start the dependencies and server on port 8080 44 | 45 | ### Running via docker 46 | 47 | 1. From root of the repo 48 | 2. Run `docker-compose up` will start the dependencies and server on port 8080 49 | 50 | ### Postman 51 | 52 | The collections will need an environment setup with `scheme`, `port` and `host` variables setup with values of `http`, `8080` and `localhost` respectively. 53 | 54 | ### Run tests 55 | 56 | Some of the integration tests use docker to spin up dependencies on demand (ie a postgres db) so just be aware that docker is needed to run the tests. 57 | 58 | 1. From root of the repo 59 | 2. Run `go test ./...` 60 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cenkalti/backoff/v4" 7 | "github.com/speakeasy-api/rest-template-go/internal/config" 8 | "github.com/speakeasy-api/rest-template-go/internal/core/app" 9 | "github.com/speakeasy-api/rest-template-go/internal/core/drivers/psql" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/listeners/http" 11 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 12 | "github.com/speakeasy-api/rest-template-go/internal/events" 13 | httptransport "github.com/speakeasy-api/rest-template-go/internal/transport/http" 14 | "github.com/speakeasy-api/rest-template-go/internal/users" 15 | "github.com/speakeasy-api/rest-template-go/internal/users/store" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | func main() { 20 | app.Start(appStart) 21 | } 22 | 23 | func appStart(ctx context.Context, a *app.App) ([]app.Listener, error) { 24 | // Load configuration from config/config.yaml which contains details such as DB connection params 25 | cfg, err := config.Load(ctx) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | // Connect to the postgres DB 31 | db, err := initDatabase(ctx, cfg, a) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | // Run our migrations which will update the DB or create it if it doesn't exist 37 | if err := db.MigratePostgres(ctx, "file://migrations"); err != nil { 38 | return nil, err 39 | } 40 | a.OnShutdown(func() { 41 | // Temp for development so database is cleared on shutdown 42 | if err := db.RevertMigrations(ctx, "file://migrations"); err != nil { 43 | logging.From(ctx).Error("failed to revert migrations", zap.Error(err)) 44 | } 45 | }) 46 | 47 | // Instantiate and connect all our classes 48 | us := store.New(db.GetDB()) 49 | e := events.New() 50 | u := users.New(us, e) 51 | 52 | httpServer := httptransport.New(u, db.GetDB()) 53 | 54 | // Create a HTTP server 55 | h, err := http.New(httpServer, cfg.HTTP) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | // Start listening for HTTP requests 61 | return []app.Listener{ 62 | h, 63 | }, nil 64 | } 65 | 66 | func initDatabase(ctx context.Context, cfg *config.Config, a *app.App) (*psql.Driver, error) { 67 | db := psql.New(cfg.PSQL) 68 | 69 | err := backoff.Retry(func() error { 70 | return db.Connect(ctx) 71 | }, backoff.NewExponentialBackOff()) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | a.OnShutdown(func() { 77 | // Shutdown connection when server terminated 78 | logging.From(ctx).Info("shutting down db connection") 79 | if err := db.Close(ctx); err != nil { 80 | logging.From(ctx).Error("failed to close db connection", zap.Error(err)) 81 | } 82 | }) 83 | 84 | return db, nil 85 | } 86 | -------------------------------------------------------------------------------- /config/config-docker.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | port: "8080" 3 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | port: "8080" 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | app: 4 | build: . 5 | ports: 6 | - "8080:8080" 7 | environment: 8 | - POSTGRES_DSN=postgresql://guest:guest@postgres:5432/speakeasy?sslmode=disable # Test credentials 9 | - SPEAKEASY_ENVIRONMENT=docker 10 | depends_on: 11 | - postgres 12 | postgres: 13 | image: postgres:alpine 14 | restart: always 15 | environment: 16 | POSTGRES_USER: guest 17 | POSTGRES_PASSWORD: guest 18 | POSTGRES_DB: speakeasy 19 | ports: 20 | - "5432:5432" 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/speakeasy-api/rest-template-go 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/AlekSi/pointer v1.2.0 7 | github.com/caarlos0/env/v6 v6.9.3 8 | github.com/cenkalti/backoff/v4 v4.1.2 9 | github.com/go-playground/validator/v10 v10.11.0 10 | github.com/golang-migrate/migrate/v4 v4.15.1 11 | github.com/golang/mock v1.6.0 12 | github.com/gorilla/mux v1.8.0 13 | github.com/jmoiron/sqlx v1.3.4 14 | github.com/lib/pq v1.10.0 15 | github.com/ory/dockertest/v3 v3.8.1 16 | github.com/stretchr/testify v1.7.1 17 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.30.0 18 | go.opentelemetry.io/otel v1.6.0 19 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.6.0 20 | go.opentelemetry.io/otel/sdk v1.6.0 21 | go.opentelemetry.io/otel/trace v1.6.0 22 | go.uber.org/zap v1.21.0 23 | gopkg.in/yaml.v2 v2.4.0 24 | ) 25 | 26 | require ( 27 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 28 | github.com/Microsoft/go-winio v0.5.2 // indirect 29 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 30 | github.com/containerd/continuity v0.2.2 // indirect 31 | github.com/davecgh/go-spew v1.1.1 // indirect 32 | github.com/docker/cli v20.10.14+incompatible // indirect 33 | github.com/docker/docker v20.10.14+incompatible // indirect 34 | github.com/docker/go-connections v0.4.0 // indirect 35 | github.com/docker/go-units v0.4.0 // indirect 36 | github.com/felixge/httpsnoop v1.0.2 // indirect 37 | github.com/go-logr/logr v1.2.3 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/go-playground/locales v0.14.0 // indirect 40 | github.com/go-playground/universal-translator v0.18.0 // indirect 41 | github.com/gogo/protobuf v1.3.2 // indirect 42 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 43 | github.com/hashicorp/errwrap v1.0.0 // indirect 44 | github.com/hashicorp/go-multierror v1.1.0 // indirect 45 | github.com/imdario/mergo v0.3.12 // indirect 46 | github.com/leodido/go-urn v1.2.1 // indirect 47 | github.com/mitchellh/mapstructure v1.4.3 // indirect 48 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 49 | github.com/opencontainers/go-digest v1.0.0 // indirect 50 | github.com/opencontainers/image-spec v1.0.2 // indirect 51 | github.com/opencontainers/runc v1.1.0 // indirect 52 | github.com/pkg/errors v0.9.1 // indirect 53 | github.com/pmezard/go-difflib v1.0.0 // indirect 54 | github.com/sirupsen/logrus v1.8.1 // indirect 55 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 56 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 57 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 58 | go.opentelemetry.io/otel/internal/metric v0.27.0 // indirect 59 | go.opentelemetry.io/otel/metric v0.27.0 // indirect 60 | go.uber.org/atomic v1.9.0 // indirect 61 | go.uber.org/multierr v1.8.0 // indirect 62 | golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect 63 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 64 | golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect 65 | golang.org/x/text v0.3.7 // indirect 66 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 67 | ) 68 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/caarlos0/env/v6" 9 | "github.com/go-playground/validator/v10" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/config" 11 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 12 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 13 | "go.uber.org/zap" 14 | "gopkg.in/yaml.v2" 15 | ) 16 | 17 | const ( 18 | // ErrInvalidEnvironment is returned when the SPEAKEASY_ENVIRONMENT environment variable is not set. 19 | ErrInvalidEnvironment = errors.Error("SPEAKEASY_ENVIRONMENT is not set") 20 | // ErrValidation is returned when the configuration is invalid. 21 | ErrValidation = errors.Error("invalid configuration") 22 | // ErrEnvVars is returned when the environment variables are invalid. 23 | ErrEnvVars = errors.Error("failed parsing env vars") 24 | // ErrRead is returned when the configuration file cannot be read. 25 | ErrRead = errors.Error("failed to read file") 26 | // ErrUnmarshal is returned when the configuration file cannot be unmarshalled. 27 | ErrUnmarshal = errors.Error("failed to unmarshal file") 28 | ) 29 | 30 | var ( 31 | baseConfigPath = "config/config.yaml" 32 | envConfigPath = "config/config-%s.yaml" 33 | ) 34 | 35 | // Config represents the configuration of our application. 36 | type Config struct { 37 | config.AppConfig `yaml:",inline"` 38 | } 39 | 40 | // Load loads the configuration from the config/config.yaml file. 41 | func Load(ctx context.Context) (*Config, error) { 42 | cfg := &Config{} 43 | 44 | if err := loadFromFiles(ctx, cfg); err != nil { 45 | return nil, err 46 | } 47 | 48 | if err := env.Parse(cfg); err != nil { 49 | return nil, ErrEnvVars.Wrap(err) 50 | } 51 | 52 | validate := validator.New() 53 | if err := validate.Struct(cfg); err != nil { 54 | return nil, ErrValidation.Wrap(err) 55 | } 56 | 57 | return cfg, nil 58 | } 59 | 60 | func loadFromFiles(ctx context.Context, cfg any) error { 61 | environ := os.Getenv("SPEAKEASY_ENVIRONMENT") 62 | if environ == "" { 63 | return ErrInvalidEnvironment 64 | } 65 | 66 | if err := loadYaml(ctx, baseConfigPath, cfg); err != nil { 67 | return err 68 | } 69 | 70 | p := fmt.Sprintf(envConfigPath, environ) 71 | 72 | if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) { 73 | if err := loadYaml(ctx, p, cfg); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func loadYaml(ctx context.Context, filename string, cfg any) error { 82 | logging.From(ctx).Info("Loading configuration", zap.String("path", filename)) 83 | 84 | data, err := os.ReadFile(filename) 85 | if err != nil { 86 | return ErrRead.Wrap(err) 87 | } 88 | 89 | if err := yaml.Unmarshal(data, cfg); err != nil { 90 | return ErrUnmarshal.Wrap(err) 91 | } 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/core/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | 11 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 12 | "github.com/speakeasy-api/rest-template-go/internal/core/tracing" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // Listener represents a type that can listen for incoming connections. 17 | type Listener interface { 18 | Listen(context.Context) error 19 | } 20 | 21 | // OnShutdownFunc is a function that is called when the app is shutdown. 22 | type OnShutdownFunc func() 23 | 24 | // App represents the application run by this service. 25 | type App struct { 26 | Name string 27 | shutdownFuncs []OnShutdownFunc 28 | } 29 | 30 | // OnStart is a function that is called when the app is started. 31 | type OnStart func(context.Context, *App) ([]Listener, error) 32 | 33 | // Start starts the application. 34 | func Start(onStart OnStart) { 35 | ctx := context.Background() 36 | 37 | a := &App{ 38 | Name: "test-app", // TODO determine how to configure this 39 | } 40 | a.OnShutdown(func() { 41 | if err := logging.Sync(ctx); err != nil { 42 | log.Printf("failed to sync logging: %v", err) 43 | } 44 | }) 45 | 46 | logging.From(ctx).Info("app starting...") 47 | 48 | if err := tracing.EnableTracing(ctx, a.Name, a); err != nil { 49 | logging.From(ctx).Fatal("failed to enable tracing", zap.Error(err)) 50 | } 51 | 52 | listeners, err := onStart(ctx, a) 53 | if err != nil { 54 | logging.From(ctx).Fatal("failed to start app", zap.Error(err)) 55 | } 56 | 57 | c := make(chan os.Signal, 1) 58 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 59 | go func() { 60 | <-c 61 | shutdown(ctx, a) 62 | os.Exit(1) 63 | }() 64 | 65 | var wg sync.WaitGroup 66 | 67 | for _, listener := range listeners { 68 | wg.Add(1) 69 | 70 | go func(l Listener) { 71 | defer wg.Done() 72 | 73 | err := l.Listen(ctx) 74 | if err != nil { 75 | logging.From(ctx).Error("listener failed", zap.Error(err)) 76 | } 77 | }(listener) 78 | } 79 | 80 | wg.Wait() 81 | 82 | shutdown(ctx, a) 83 | } 84 | 85 | // OnShutdown registers a function that is called when the app is shutdown. 86 | func (a *App) OnShutdown(onShutdown func()) { 87 | a.shutdownFuncs = append([]OnShutdownFunc{onShutdown}, a.shutdownFuncs...) 88 | } 89 | 90 | func shutdown(ctx context.Context, a *App) { 91 | for _, shutdownFunc := range a.shutdownFuncs { 92 | shutdownFunc() 93 | } 94 | 95 | logging.From(ctx).Info("app shutdown") 96 | } 97 | -------------------------------------------------------------------------------- /internal/core/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/speakeasy-api/rest-template-go/internal/core/drivers/psql" 7 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 8 | "github.com/speakeasy-api/rest-template-go/internal/core/listeners/http" 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | const ( 13 | // ErrRead is returned when we cannot read the config file. 14 | ErrRead = errors.Error("failed to read file") 15 | // ErrUnmarshal is returned when we cannot unmarshal the config file. 16 | ErrUnmarshal = errors.Error("failed to unmarshal file") 17 | ) 18 | 19 | // AppConfig represents the configuration of our application. 20 | type AppConfig struct { 21 | HTTP http.Config `yaml:"http"` 22 | PSQL psql.Config `yaml:"psql"` 23 | } 24 | 25 | // Load loads the configuration from a yaml file on disk. 26 | func Load(cfg interface{}) error { 27 | data, err := os.ReadFile("config/config.yaml") // TODO support different environments 28 | if err != nil { 29 | return ErrRead.Wrap(err) 30 | } 31 | 32 | if err := yaml.Unmarshal(data, cfg); err != nil { 33 | return ErrUnmarshal.Wrap(err) 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/core/drivers/psql/migrations.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/golang-migrate/migrate/v4" 7 | "github.com/golang-migrate/migrate/v4/database/postgres" 8 | _ "github.com/golang-migrate/migrate/v4/source/file" // import file driver for migrate 9 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 11 | ) 12 | 13 | const ( 14 | // ErrDriverInit is returned when we cannot initialize the driver. 15 | ErrDriverInit = errors.Error("failed to initialize postgres driver") 16 | // ErrMigrateInit is returned when we cannot initialize the migrate driver. 17 | ErrMigrateInit = errors.Error("failed to initialize migration driver") 18 | // ErrMigration is returned when we cannot run a migration. 19 | ErrMigration = errors.Error("failed to migrate database") 20 | ) 21 | 22 | // MigratePostgres migrates the database to the latest version. 23 | func (d *Driver) MigratePostgres(ctx context.Context, migrationsPath string) error { 24 | driver, err := postgres.WithInstance(d.db.DB, &postgres.Config{}) 25 | if err != nil { 26 | return ErrDriverInit.Wrap(err) 27 | } 28 | 29 | m, err := migrate.NewWithDatabaseInstance(migrationsPath, "postgres", driver) 30 | if err != nil { 31 | return ErrMigrateInit.Wrap(err) 32 | } 33 | 34 | if err := m.Up(); err != nil { 35 | if errors.Is(err, migrate.ErrNoChange) { 36 | logging.From(ctx).Info("no migrations to run") 37 | } else { 38 | return ErrMigration.Wrap(err) 39 | } 40 | } 41 | 42 | logging.From(ctx).Info("migrations successfully run") 43 | 44 | return nil 45 | } 46 | 47 | // RevertMigrations reverts the database to the previous version. 48 | func (d *Driver) RevertMigrations(ctx context.Context, migrationsPath string) error { 49 | driver, err := postgres.WithInstance(d.db.DB, &postgres.Config{}) 50 | if err != nil { 51 | return ErrDriverInit.Wrap(err) 52 | } 53 | 54 | m, err := migrate.NewWithDatabaseInstance(migrationsPath, "postgres", driver) 55 | if err != nil { 56 | return ErrMigrateInit.Wrap(err) 57 | } 58 | 59 | if err := m.Down(); err != nil { 60 | return ErrMigration.Wrap(err) 61 | } 62 | 63 | logging.From(ctx).Info("migrations successfully reverted") 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/core/drivers/psql/psql.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | _ "github.com/lib/pq" // imports the postgres driver 8 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 9 | ) 10 | 11 | const ( 12 | // ErrConnect is returned when we cannot connect to the database. 13 | ErrConnect = errors.Error("failed to connect to postgres db") 14 | // ErrClose is returned when we cannot close the database. 15 | ErrClose = errors.Error("failed to close postgres db connection") 16 | ) 17 | 18 | // Config represents the configuration for our postgres database. 19 | type Config struct { 20 | DSN string `env:"POSTGRES_DSN" validate:"required"` 21 | } 22 | 23 | // Driver provides an implementation for connecting to a postgres database. 24 | type Driver struct { 25 | cfg Config 26 | db *sqlx.DB 27 | } 28 | 29 | // New instantiates a instance of the Driver. 30 | func New(cfg Config) *Driver { 31 | return &Driver{ 32 | cfg: cfg, 33 | } 34 | } 35 | 36 | // Connect connects to the database. 37 | func (d *Driver) Connect(ctx context.Context) error { 38 | db, err := sqlx.Connect("postgres", d.cfg.DSN) 39 | if err != nil { 40 | return ErrConnect.Wrap(err) 41 | } 42 | 43 | d.db = db 44 | 45 | return nil 46 | } 47 | 48 | // Close closes the database connection. 49 | func (d *Driver) Close(ctx context.Context) error { 50 | if err := d.db.Close(); err != nil { 51 | return ErrClose.Wrap(err) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // GetDB returns the underlying database connection. 58 | func (d *Driver) GetDB() *sqlx.DB { 59 | return d.db 60 | } 61 | -------------------------------------------------------------------------------- /internal/core/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | // ErrUnknown is returned when an unexpected error occurs. 12 | ErrUnknown = Error("err_unknown: unknown error occurred") 13 | // ErrInvalidRequest is returned when either the parameters or the request body is invalid. 14 | ErrInvalidRequest = Error("err_invalid_request: invalid request received") 15 | // ErrValidation is returned when the parameters don't pass validation. 16 | ErrValidation = Error("err_validation: failed validation") 17 | // ErrNotFound is returned when the requested resource is not found. 18 | ErrNotFound = Error("err_not_found: not found") 19 | ) 20 | 21 | // ErrSeperator is used to determine the boundaries of the errors in the hierarchy. 22 | const ErrSeperator = " -- " 23 | 24 | // Error allows errors to be defined as const errors preventing modification 25 | // and allowing them to be evaluated against wrapped errors. 26 | type Error string 27 | 28 | func (s Error) Error() string { 29 | return string(s) 30 | } 31 | 32 | // Is implements https://golang.org/pkg/errors/#Is allowing a Error 33 | // to check it is the same even when wrapped. This implementation only 34 | // checks the top most wrapped error. 35 | func (s Error) Is(target error) bool { 36 | return s.Error() == target.Error() || strings.HasPrefix(target.Error(), s.Error()+ErrSeperator) 37 | } 38 | 39 | // As implements As(interface{}) bool which is used by errors.As 40 | // (https://golang.org/pkg/errors/#As) allowing a Error to be set as the 41 | // target if it matches the specified target type. This implementation 42 | // only checks the top most wrapped error. 43 | func (s Error) As(target interface{}) bool { 44 | v := reflect.ValueOf(target).Elem() 45 | if v.Type().Name() == "Error" && v.CanSet() { 46 | v.SetString(string(s)) 47 | return true 48 | } 49 | return false 50 | } 51 | 52 | // Wrap allows errors to wrap an error returned from a 3rd party in 53 | // a const service error preserving the original cause. 54 | func (s Error) Wrap(err error) error { 55 | return wrappedError{cause: err, msg: string(s)} 56 | } 57 | 58 | // wrappedError is an internal error type that allows the wrapping of 59 | // underlying errors with Errors. 60 | type wrappedError struct { 61 | cause error 62 | msg string 63 | } 64 | 65 | func (w wrappedError) Error() string { 66 | if w.cause != nil { 67 | return fmt.Sprintf("%s%s%v", w.msg, ErrSeperator, w.cause) 68 | } 69 | return w.msg 70 | } 71 | 72 | // Is for a wrapped error allows it to be compared against const Errors. 73 | func (w wrappedError) Is(target error) bool { 74 | return Error(w.msg).Is(target) 75 | } 76 | 77 | // As allows it to be compared and set if the target type matches 78 | // wrappedError. 79 | func (w wrappedError) As(target interface{}) bool { 80 | return Error(w.msg).As(target) 81 | } 82 | 83 | // Implements https://golang.org/pkg/errors/#Unwrap allow the cause 84 | // error to be retrieved. 85 | func (w wrappedError) Unwrap() error { 86 | return w.cause 87 | } 88 | 89 | // New just wraps errors.New as we don't want to alias the errors package everywhere to use it. 90 | func New(message string) error { 91 | //nolint:goerr113 92 | return errors.New(message) 93 | } 94 | 95 | // Is just wraps errors.Is as we don't want to alias the errors package everywhere to use it. 96 | func Is(err error, target error) bool { 97 | return errors.Is(err, target) 98 | } 99 | 100 | // As just wraps errors.As as we don't want to alias the errors package everywhere to use it. 101 | func As(err error, target any) bool { 102 | return errors.As(err, target) 103 | } 104 | -------------------------------------------------------------------------------- /internal/core/listeners/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 12 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 13 | ) 14 | 15 | const ( 16 | // ErrAddRoutes is the error returned when adding routes to the router fails. 17 | ErrAddRoutes = errors.Error("failed to add routes") 18 | // ErrServer is the error returned when the server stops due to an error. 19 | ErrServer = errors.Error("listen stopped with error") 20 | ) 21 | 22 | const ( 23 | readHeaderTimeout = 60 * time.Second 24 | ) 25 | 26 | // Config represents the configuration of the http listener. 27 | type Config struct { 28 | Port string `yaml:"port"` 29 | } 30 | 31 | // Service represents a http service that provides routes for the listener. 32 | type Service interface { 33 | AddRoutes(r *mux.Router) error 34 | } 35 | 36 | // Server represents a http server that listens on a port. 37 | type Server struct { 38 | server *http.Server 39 | port string 40 | } 41 | 42 | // New instantiates a new instance of Server. 43 | func New(s Service, cfg Config) (*Server, error) { 44 | r := mux.NewRouter() 45 | r.Use(tracingMiddleware) 46 | r.Use(logTracingMiddleware) 47 | r.Use(requestLoggingMiddleware) 48 | 49 | if err := s.AddRoutes(r); err != nil { 50 | return nil, ErrAddRoutes.Wrap(err) 51 | } 52 | 53 | return &Server{ 54 | server: &http.Server{ 55 | Addr: fmt.Sprintf(":%s", cfg.Port), 56 | BaseContext: func(net.Listener) context.Context { 57 | baseContext := context.Background() 58 | return logging.With(baseContext, logging.From(baseContext)) 59 | }, 60 | Handler: r, 61 | ReadHeaderTimeout: readHeaderTimeout, 62 | }, 63 | port: cfg.Port, 64 | }, nil 65 | } 66 | 67 | // Listen starts the server and listens on the configured port. 68 | func (s *Server) Listen(ctx context.Context) error { 69 | logging.From(ctx).Info(fmt.Sprintf("http server starting on port: %s", s.port)) 70 | 71 | err := s.server.ListenAndServe() 72 | if err != nil { 73 | return ErrServer.Wrap(err) 74 | } 75 | 76 | logging.From(ctx).Info("http server stopped") 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/core/listeners/http/logging.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func requestLoggingMiddleware(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | ctx := logging.WithFields(r.Context(), zap.String("uri", r.RequestURI)) 13 | 14 | logging.From(ctx).Info("request", zap.String("method", r.Method)) 15 | 16 | next.ServeHTTP(w, r.WithContext(ctx)) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/core/listeners/http/tracing.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 7 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 8 | "go.opentelemetry.io/otel/trace" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func tracingMiddleware(next http.Handler) http.Handler { 13 | return otelhttp.NewHandler(next, "example-rest-service") 14 | } 15 | 16 | func logTracingMiddleware(next http.Handler) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | ctx := r.Context() 19 | 20 | span := trace.SpanFromContext(ctx) 21 | 22 | traceID := span.SpanContext().TraceID() 23 | spanID := span.SpanContext().SpanID() 24 | 25 | ctx = logging.WithFields(ctx, zap.String("trace_id", traceID.String()), zap.String("span_id", spanID.String())) 26 | 27 | next.ServeHTTP(w, r.WithContext(ctx)) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/core/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | type contextKey int 12 | 13 | const loggerKey contextKey = iota 14 | 15 | var defaultLogger = zap.New(zapcore.NewCore( 16 | zapcore.NewJSONEncoder(zapcore.EncoderConfig{ 17 | TimeKey: "@timestamp", 18 | LevelKey: "level", 19 | NameKey: "logger", 20 | CallerKey: "caller", 21 | MessageKey: "msg", 22 | StacktraceKey: "stacktrace", 23 | LineEnding: zapcore.DefaultLineEnding, 24 | EncodeLevel: zapcore.LowercaseLevelEncoder, 25 | EncodeTime: zapcore.RFC3339NanoTimeEncoder, 26 | EncodeDuration: zapcore.NanosDurationEncoder, 27 | EncodeCaller: zapcore.ShortCallerEncoder, 28 | }), 29 | zapcore.AddSync(os.Stdout), 30 | zap.NewAtomicLevelAt(zapcore.InfoLevel), 31 | ), zap.AddCaller(), zap.AddCallerSkip(1)) 32 | 33 | // From returns the logger associated with the given context. 34 | func From(ctx context.Context) *zap.Logger { 35 | if l, ok := ctx.Value(loggerKey).(*zap.Logger); ok { 36 | return l 37 | } 38 | return defaultLogger 39 | } 40 | 41 | // With returns a new context with the provided logger. 42 | func With(ctx context.Context, l *zap.Logger) context.Context { 43 | return context.WithValue(ctx, loggerKey, l) 44 | } 45 | 46 | // WithFields returns a new context with the provided fields attached as metadata to future loggers. 47 | func WithFields(ctx context.Context, fields ...zap.Field) context.Context { 48 | if len(fields) == 0 { 49 | return ctx 50 | } 51 | return With(ctx, From(ctx).With(fields...)) 52 | } 53 | 54 | // Sync flushes any buffered log entries. 55 | func Sync(ctx context.Context) error { 56 | return From(ctx).Sync() 57 | } 58 | -------------------------------------------------------------------------------- /internal/core/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 10 | "go.opentelemetry.io/otel/sdk/resource" 11 | "go.opentelemetry.io/otel/sdk/trace" 12 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 13 | ) 14 | 15 | var tp *trace.TracerProvider 16 | 17 | // OnShutdowner is an interface that allows a caller to register a function to be called when the application is shutting down. 18 | type OnShutdowner interface { 19 | OnShutdown(onShutdown func()) 20 | } 21 | 22 | // EnableTracing enables tracing. 23 | func EnableTracing(ctx context.Context, appName string, s OnShutdowner) error { 24 | exp, err := stdouttrace.New( 25 | stdouttrace.WithWriter(io.Discard), 26 | stdouttrace.WithPrettyPrint(), 27 | stdouttrace.WithoutTimestamps(), 28 | ) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | tp = trace.NewTracerProvider( 34 | trace.WithBatcher(exp), 35 | trace.WithResource(newResource(appName)), 36 | ) 37 | s.OnShutdown(func() { 38 | logging.From(ctx).Info("shutting down tracing provider") 39 | 40 | if err := tp.Shutdown(ctx); err != nil { 41 | logging.From(ctx).Error("failed to shutdown tracing provider") 42 | } 43 | }) 44 | otel.SetTracerProvider(tp) 45 | 46 | return nil 47 | } 48 | 49 | func newResource(appName string) *resource.Resource { 50 | r, _ := resource.Merge( 51 | resource.Default(), 52 | resource.NewWithAttributes( 53 | semconv.SchemaURL, 54 | semconv.ServiceNameKey.String(appName), 55 | ), 56 | ) 57 | return r 58 | } 59 | -------------------------------------------------------------------------------- /internal/events/events.go: -------------------------------------------------------------------------------- 1 | // Package events represents a stub for producing events to a Kafka topic, 2 | // a real implementation would contain logic for retrying failed events etc 3 | package events 4 | 5 | import "context" 6 | 7 | // Topic represents a topic in Kafka. 8 | type Topic string 9 | 10 | const ( 11 | // TopicUsers represents a topic for user entity events such as CRUD events. 12 | TopicUsers Topic = "users" 13 | ) 14 | 15 | // Events represents an implementation that can produce events. 16 | type Events struct{} 17 | 18 | // New will instantiate a new instance of Events. 19 | func New() *Events { 20 | return &Events{} 21 | } 22 | 23 | // Produce will produce an event on the given topic using the supplied payload. 24 | func (e *Events) Produce(ctx context.Context, topic Topic, payload interface{}) { 25 | // TODO implement a Kafka producer implementation 26 | // ideally the payload would be a protobuf message from a shared schema package 27 | // for this exercise we will pass a struct that can be marshalled to JSON 28 | 29 | // The main implementation would happen asynchronously to avoid blocking the producing call 30 | } 31 | -------------------------------------------------------------------------------- /internal/events/model.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "github.com/speakeasy-api/rest-template-go/internal/users/model" 4 | 5 | // EventType represents the type of event that occurred. 6 | type EventType string 7 | 8 | const ( 9 | // EventTypeUserCreated is triggered after a user has been successfully created. 10 | EventTypeUserCreated EventType = "user_created" 11 | // EventTypeUserUpdated is triggered after a user has been successfully updated. 12 | EventTypeUserUpdated EventType = "user_updated" 13 | // EventTypeUserDeleted is triggered after a user has been successfully deleted. 14 | EventTypeUserDeleted EventType = "user_deleted" 15 | ) 16 | 17 | // UserEvent represents an event that occurs on a user entity. 18 | type UserEvent struct { 19 | EventType EventType `json:"event_type"` 20 | ID string `json:"id"` 21 | User *model.User `json:"user"` 22 | } 23 | -------------------------------------------------------------------------------- /internal/transport/http/errors.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | func handleError(ctx context.Context, w http.ResponseWriter, err error) { 15 | logging.From(ctx).Error("error occurred in request", zap.Error(err)) 16 | 17 | switch { 18 | case errors.Is(err, errors.ErrInvalidRequest): 19 | fallthrough 20 | case errors.Is(err, errors.ErrValidation): 21 | w.WriteHeader(http.StatusBadRequest) 22 | case errors.Is(err, errors.ErrNotFound): 23 | w.WriteHeader(http.StatusNotFound) 24 | case errors.Is(err, errors.ErrUnknown): 25 | fallthrough 26 | default: 27 | w.WriteHeader(http.StatusInternalServerError) 28 | } 29 | 30 | errJSON := struct { 31 | Error string `json:"error"` 32 | }{ 33 | Error: strings.Split(err.Error(), errors.ErrSeperator)[0], // TODO we may need to strip additional error information 34 | } 35 | 36 | data, err := json.Marshal(errJSON) 37 | if err != nil { 38 | logging.From(ctx).Error("failed to serialize error response", zap.Error(err)) 39 | data = []byte(`{"error": "internal server error"}`) 40 | } 41 | 42 | _, err = w.Write(data) 43 | if err != nil { 44 | logging.From(ctx).Error("failed to write error response", zap.Error(err)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/transport/http/http.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -destination=./mocks/http_mock.go -package mocks github.com/speakeasy-api/rest-template-go/internal/transport/http Users,DB 2 | 3 | package http 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "net/http" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 12 | ) 13 | 14 | // Users represents a type that can provide CRUD operations on users. 15 | type Users interface { 16 | CreateUser(ctx context.Context, user *model.User) (*model.User, error) 17 | GetUser(ctx context.Context, id string) (*model.User, error) 18 | FindUsers(ctx context.Context, filters []model.Filter, offset, limit int64) ([]*model.User, error) 19 | UpdateUser(ctx context.Context, user *model.User) (*model.User, error) 20 | DeleteUser(ctx context.Context, id string) error 21 | } 22 | 23 | // DB represents a type that can be used to interact with the database. 24 | type DB interface { 25 | PingContext(ctx context.Context) error 26 | } 27 | 28 | // Server represents a HTTP server that can handle requests for this microservice. 29 | type Server struct { 30 | users Users 31 | db DB 32 | } 33 | 34 | // New will instantiate a new instance of Server. 35 | func New(u Users, db DB) *Server { 36 | return &Server{ 37 | users: u, 38 | db: db, 39 | } 40 | } 41 | 42 | // AddRoutes will add the routes this server supports to the router. 43 | func (s *Server) AddRoutes(r *mux.Router) error { 44 | r.HandleFunc("/health", s.healthCheck).Methods(http.MethodGet) 45 | 46 | r = r.PathPrefix("/v1").Subrouter() 47 | 48 | r.HandleFunc("/user", s.createUser).Methods(http.MethodPost) 49 | r.HandleFunc("/user/{id}", s.getUser).Methods(http.MethodGet) 50 | r.HandleFunc("/user/{id}", s.updateUser).Methods(http.MethodPut) 51 | r.HandleFunc("/user/{id}", s.deleteUser).Methods(http.MethodDelete) 52 | 53 | // Not the most RESTful way of doing this as it won't really be cachable but provides easier parsing of the inputs for now 54 | r.HandleFunc("/users/search", s.searchUsers).Methods(http.MethodPost) 55 | 56 | return nil 57 | } 58 | 59 | func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) { 60 | if err := s.db.PingContext(r.Context()); err != nil { 61 | handleError(r.Context(), w, err) 62 | return 63 | } 64 | 65 | w.WriteHeader(http.StatusOK) 66 | } 67 | 68 | func handleResponse(ctx context.Context, w http.ResponseWriter, data interface{}) { 69 | jsonRes := struct { 70 | Data interface{} `json:"data"` 71 | }{ 72 | Data: data, 73 | } 74 | 75 | dataBytes, err := json.Marshal(jsonRes) 76 | if err != nil { 77 | handleError(ctx, w, err) 78 | return 79 | } 80 | 81 | if _, err := w.Write(dataBytes); err != nil { 82 | handleError(ctx, w, err) 83 | return 84 | } 85 | 86 | w.WriteHeader(http.StatusOK) 87 | } 88 | -------------------------------------------------------------------------------- /internal/transport/http/http_integration_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/golang/mock/gomock" 11 | "github.com/gorilla/mux" 12 | httptransport "github.com/speakeasy-api/rest-template-go/internal/transport/http" 13 | "github.com/speakeasy-api/rest-template-go/internal/transport/http/mocks" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestServer_Health_Success(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | wantCode int 22 | }{ 23 | { 24 | name: "success", 25 | wantCode: http.StatusOK, 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | ctrl := gomock.NewController(t) 31 | defer ctrl.Finish() 32 | 33 | u := mocks.NewMockUsers(ctrl) 34 | d := mocks.NewMockDB(ctrl) 35 | 36 | ht := httptransport.New(u, d) 37 | require.NotNil(t, ht) 38 | 39 | r := mux.NewRouter() 40 | 41 | err := ht.AddRoutes(r) 42 | require.NoError(t, err) 43 | 44 | w := httptest.NewRecorder() 45 | 46 | d.EXPECT().PingContext(gomock.Any()).Return(nil).Times(1) 47 | 48 | req, err := http.NewRequest(http.MethodGet, "/health", nil) 49 | require.NoError(t, err) 50 | 51 | r.ServeHTTP(w, req) 52 | 53 | assert.Equal(t, tt.wantCode, w.Code) 54 | }) 55 | } 56 | } 57 | 58 | func TestServer_Health_Error(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | wantErr string 62 | wantCode int 63 | }{ 64 | { 65 | name: "fails", 66 | wantErr: "test fail", 67 | wantCode: http.StatusInternalServerError, 68 | }, 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | ctrl := gomock.NewController(t) 73 | defer ctrl.Finish() 74 | 75 | u := mocks.NewMockUsers(ctrl) 76 | d := mocks.NewMockDB(ctrl) 77 | 78 | ht := httptransport.New(u, d) 79 | require.NotNil(t, ht) 80 | 81 | r := mux.NewRouter() 82 | 83 | err := ht.AddRoutes(r) 84 | require.NoError(t, err) 85 | 86 | w := httptest.NewRecorder() 87 | 88 | d.EXPECT().PingContext(gomock.Any()).Return(errors.New(tt.wantErr)).Times(1) 89 | 90 | req, err := http.NewRequest(http.MethodGet, "/health", nil) 91 | require.NoError(t, err) 92 | 93 | r.ServeHTTP(w, req) 94 | 95 | assert.Equal(t, tt.wantCode, w.Code) 96 | 97 | var res struct { 98 | Error string `json:"error"` 99 | } 100 | 101 | err = json.Unmarshal(w.Body.Bytes(), &res) 102 | require.NoError(t, err) 103 | assert.Equal(t, tt.wantErr, res.Error) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/transport/http/mocks/http_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/speakeasy-api/rest-template-go/internal/transport/http (interfaces: Users,DB) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | model "github.com/speakeasy-api/rest-template-go/internal/users/model" 10 | reflect "reflect" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | ) 14 | 15 | // MockUsers is a mock of Users interface. 16 | type MockUsers struct { 17 | ctrl *gomock.Controller 18 | recorder *MockUsersMockRecorder 19 | } 20 | 21 | // MockUsersMockRecorder is the mock recorder for MockUsers. 22 | type MockUsersMockRecorder struct { 23 | mock *MockUsers 24 | } 25 | 26 | // NewMockUsers creates a new mock instance. 27 | func NewMockUsers(ctrl *gomock.Controller) *MockUsers { 28 | mock := &MockUsers{ctrl: ctrl} 29 | mock.recorder = &MockUsersMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockUsers) EXPECT() *MockUsersMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // CreateUser mocks base method. 39 | func (m *MockUsers) CreateUser(arg0 context.Context, arg1 *model.User) (*model.User, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "CreateUser", arg0, arg1) 42 | ret0, _ := ret[0].(*model.User) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // CreateUser indicates an expected call of CreateUser. 48 | func (mr *MockUsersMockRecorder) CreateUser(arg0, arg1 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockUsers)(nil).CreateUser), arg0, arg1) 51 | } 52 | 53 | // DeleteUser mocks base method. 54 | func (m *MockUsers) DeleteUser(arg0 context.Context, arg1 string) error { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "DeleteUser", arg0, arg1) 57 | ret0, _ := ret[0].(error) 58 | return ret0 59 | } 60 | 61 | // DeleteUser indicates an expected call of DeleteUser. 62 | func (mr *MockUsersMockRecorder) DeleteUser(arg0, arg1 interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockUsers)(nil).DeleteUser), arg0, arg1) 65 | } 66 | 67 | // FindUsers mocks base method. 68 | func (m *MockUsers) FindUsers(arg0 context.Context, arg1 []model.Filter, arg2, arg3 int64) ([]*model.User, error) { 69 | m.ctrl.T.Helper() 70 | ret := m.ctrl.Call(m, "FindUsers", arg0, arg1, arg2, arg3) 71 | ret0, _ := ret[0].([]*model.User) 72 | ret1, _ := ret[1].(error) 73 | return ret0, ret1 74 | } 75 | 76 | // FindUsers indicates an expected call of FindUsers. 77 | func (mr *MockUsersMockRecorder) FindUsers(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUsers", reflect.TypeOf((*MockUsers)(nil).FindUsers), arg0, arg1, arg2, arg3) 80 | } 81 | 82 | // GetUser mocks base method. 83 | func (m *MockUsers) GetUser(arg0 context.Context, arg1 string) (*model.User, error) { 84 | m.ctrl.T.Helper() 85 | ret := m.ctrl.Call(m, "GetUser", arg0, arg1) 86 | ret0, _ := ret[0].(*model.User) 87 | ret1, _ := ret[1].(error) 88 | return ret0, ret1 89 | } 90 | 91 | // GetUser indicates an expected call of GetUser. 92 | func (mr *MockUsersMockRecorder) GetUser(arg0, arg1 interface{}) *gomock.Call { 93 | mr.mock.ctrl.T.Helper() 94 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockUsers)(nil).GetUser), arg0, arg1) 95 | } 96 | 97 | // UpdateUser mocks base method. 98 | func (m *MockUsers) UpdateUser(arg0 context.Context, arg1 *model.User) (*model.User, error) { 99 | m.ctrl.T.Helper() 100 | ret := m.ctrl.Call(m, "UpdateUser", arg0, arg1) 101 | ret0, _ := ret[0].(*model.User) 102 | ret1, _ := ret[1].(error) 103 | return ret0, ret1 104 | } 105 | 106 | // UpdateUser indicates an expected call of UpdateUser. 107 | func (mr *MockUsersMockRecorder) UpdateUser(arg0, arg1 interface{}) *gomock.Call { 108 | mr.mock.ctrl.T.Helper() 109 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockUsers)(nil).UpdateUser), arg0, arg1) 110 | } 111 | 112 | // MockDB is a mock of DB interface. 113 | type MockDB struct { 114 | ctrl *gomock.Controller 115 | recorder *MockDBMockRecorder 116 | } 117 | 118 | // MockDBMockRecorder is the mock recorder for MockDB. 119 | type MockDBMockRecorder struct { 120 | mock *MockDB 121 | } 122 | 123 | // NewMockDB creates a new mock instance. 124 | func NewMockDB(ctrl *gomock.Controller) *MockDB { 125 | mock := &MockDB{ctrl: ctrl} 126 | mock.recorder = &MockDBMockRecorder{mock} 127 | return mock 128 | } 129 | 130 | // EXPECT returns an object that allows the caller to indicate expected use. 131 | func (m *MockDB) EXPECT() *MockDBMockRecorder { 132 | return m.recorder 133 | } 134 | 135 | // PingContext mocks base method. 136 | func (m *MockDB) PingContext(arg0 context.Context) error { 137 | m.ctrl.T.Helper() 138 | ret := m.ctrl.Call(m, "PingContext", arg0) 139 | ret0, _ := ret[0].(error) 140 | return ret0 141 | } 142 | 143 | // PingContext indicates an expected call of PingContext. 144 | func (mr *MockDBMockRecorder) PingContext(arg0 interface{}) *gomock.Call { 145 | mr.mock.ctrl.T.Helper() 146 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PingContext", reflect.TypeOf((*MockDB)(nil).PingContext), arg0) 147 | } 148 | -------------------------------------------------------------------------------- /internal/transport/http/user_integration_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | "time" 12 | 13 | "github.com/AlekSi/pointer" 14 | "github.com/golang/mock/gomock" 15 | "github.com/gorilla/mux" 16 | httptransport "github.com/speakeasy-api/rest-template-go/internal/transport/http" 17 | "github.com/speakeasy-api/rest-template-go/internal/transport/http/mocks" 18 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | const ( 24 | baseUserURL = "/v1/user" 25 | userURL = baseUserURL + "/%s" 26 | searchURL = "/v1/users/search" 27 | ) 28 | 29 | func TestServer_CreateUser_Success(t *testing.T) { 30 | type args struct { 31 | user model.User 32 | } 33 | tests := []struct { 34 | name string 35 | args args 36 | wantUser model.User 37 | wantCode int 38 | }{ 39 | { 40 | name: "success", 41 | args: args{ 42 | user: model.User{ 43 | FirstName: pointer.ToString("testFirst"), 44 | LastName: pointer.ToString("testLast"), 45 | Nickname: pointer.ToString("test"), 46 | Password: pointer.ToString("test"), 47 | Email: pointer.ToString("test@test.com"), 48 | Country: pointer.ToString("UK"), 49 | }, 50 | }, 51 | wantUser: model.User{ 52 | ID: pointer.ToString("some-test-id"), 53 | FirstName: pointer.ToString("testFirst"), 54 | LastName: pointer.ToString("testLast"), 55 | Nickname: pointer.ToString("test"), 56 | Password: pointer.ToString("test"), 57 | Email: pointer.ToString("test@test.com"), 58 | Country: pointer.ToString("UK"), 59 | CreatedAt: pointer.ToTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), 60 | UpdatedAt: pointer.ToTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), 61 | }, 62 | wantCode: http.StatusOK, 63 | }, 64 | } 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | ctrl := gomock.NewController(t) 68 | defer ctrl.Finish() 69 | 70 | u := mocks.NewMockUsers(ctrl) 71 | d := mocks.NewMockDB(ctrl) 72 | 73 | ht := httptransport.New(u, d) 74 | require.NotNil(t, ht) 75 | 76 | r := mux.NewRouter() 77 | 78 | err := ht.AddRoutes(r) 79 | require.NoError(t, err) 80 | 81 | w := httptest.NewRecorder() 82 | 83 | u.EXPECT().CreateUser(gomock.Any(), &tt.args.user).Return(&tt.wantUser, nil).Times(1) 84 | 85 | data, err := json.Marshal(tt.args.user) 86 | require.NoError(t, err) 87 | require.NotNil(t, data) 88 | 89 | req, err := http.NewRequest(http.MethodPost, baseUserURL, bytes.NewBuffer(data)) 90 | require.NoError(t, err) 91 | 92 | r.ServeHTTP(w, req) 93 | 94 | assert.Equal(t, tt.wantCode, w.Code) 95 | 96 | var res struct { 97 | Data model.User `json:"data"` 98 | } 99 | 100 | err = json.Unmarshal(w.Body.Bytes(), &res) 101 | require.NoError(t, err) 102 | assert.Equal(t, tt.wantUser, res.Data) 103 | }) 104 | } 105 | } 106 | 107 | func TestServer_CreateUser_Error(t *testing.T) { 108 | type args struct { 109 | user model.User 110 | } 111 | tests := []struct { 112 | name string 113 | args args 114 | wantErr string 115 | wantCode int 116 | }{ 117 | { 118 | name: "fails", 119 | args: args{ 120 | user: model.User{ 121 | FirstName: pointer.ToString("testFirst"), 122 | LastName: pointer.ToString("testLast"), 123 | Nickname: pointer.ToString("test"), 124 | Password: pointer.ToString("test"), 125 | Email: pointer.ToString("test@test.com"), 126 | Country: pointer.ToString("UK"), 127 | }, 128 | }, 129 | wantErr: "test fail", 130 | wantCode: http.StatusInternalServerError, 131 | }, 132 | } 133 | for _, tt := range tests { 134 | t.Run(tt.name, func(t *testing.T) { 135 | ctrl := gomock.NewController(t) 136 | defer ctrl.Finish() 137 | 138 | u := mocks.NewMockUsers(ctrl) 139 | d := mocks.NewMockDB(ctrl) 140 | 141 | ht := httptransport.New(u, d) 142 | require.NotNil(t, ht) 143 | 144 | r := mux.NewRouter() 145 | 146 | err := ht.AddRoutes(r) 147 | require.NoError(t, err) 148 | 149 | w := httptest.NewRecorder() 150 | 151 | u.EXPECT().CreateUser(gomock.Any(), &tt.args.user).Return(nil, errors.New(tt.wantErr)).Times(1) 152 | 153 | data, err := json.Marshal(tt.args.user) 154 | require.NoError(t, err) 155 | require.NotNil(t, data) 156 | 157 | req, err := http.NewRequest(http.MethodPost, baseUserURL, bytes.NewBuffer(data)) 158 | require.NoError(t, err) 159 | 160 | r.ServeHTTP(w, req) 161 | 162 | assert.Equal(t, tt.wantCode, w.Code) 163 | 164 | var res struct { 165 | Error string `json:"error"` 166 | } 167 | 168 | err = json.Unmarshal(w.Body.Bytes(), &res) 169 | require.NoError(t, err) 170 | assert.Equal(t, tt.wantErr, res.Error) 171 | }) 172 | } 173 | } 174 | 175 | func TestServer_GetUser_Success(t *testing.T) { 176 | type args struct { 177 | id string 178 | } 179 | tests := []struct { 180 | name string 181 | args args 182 | wantUser model.User 183 | wantCode int 184 | }{ 185 | { 186 | name: "success", 187 | args: args{ 188 | id: "some-test-id", 189 | }, 190 | wantUser: model.User{ 191 | ID: pointer.ToString("some-test-id"), 192 | FirstName: pointer.ToString("testFirst"), 193 | LastName: pointer.ToString("testLast"), 194 | Nickname: pointer.ToString("test"), 195 | Password: pointer.ToString("test"), 196 | Email: pointer.ToString("test@test.com"), 197 | Country: pointer.ToString("UK"), 198 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 199 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 200 | }, 201 | wantCode: http.StatusOK, 202 | }, 203 | } 204 | for _, tt := range tests { 205 | t.Run(tt.name, func(t *testing.T) { 206 | ctrl := gomock.NewController(t) 207 | defer ctrl.Finish() 208 | 209 | u := mocks.NewMockUsers(ctrl) 210 | d := mocks.NewMockDB(ctrl) 211 | 212 | ht := httptransport.New(u, d) 213 | require.NotNil(t, ht) 214 | 215 | r := mux.NewRouter() 216 | 217 | err := ht.AddRoutes(r) 218 | require.NoError(t, err) 219 | 220 | w := httptest.NewRecorder() 221 | 222 | u.EXPECT().GetUser(gomock.Any(), tt.args.id).Return(&tt.wantUser, nil).Times(1) 223 | 224 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(userURL, tt.args.id), nil) 225 | require.NoError(t, err) 226 | 227 | r.ServeHTTP(w, req) 228 | 229 | assert.Equal(t, tt.wantCode, w.Code) 230 | 231 | var res struct { 232 | Data model.User `json:"data"` 233 | } 234 | 235 | err = json.Unmarshal(w.Body.Bytes(), &res) 236 | require.NoError(t, err) 237 | assert.EqualValues(t, tt.wantUser, res.Data) 238 | }) 239 | } 240 | } 241 | 242 | func TestServer_GetUser_Error(t *testing.T) { 243 | type args struct { 244 | id string 245 | } 246 | tests := []struct { 247 | name string 248 | args args 249 | wantErr string 250 | wantCode int 251 | }{ 252 | { 253 | name: "fails", 254 | args: args{ 255 | id: "some-test-id", 256 | }, 257 | wantErr: "test fail", 258 | wantCode: http.StatusInternalServerError, 259 | }, 260 | } 261 | for _, tt := range tests { 262 | t.Run(tt.name, func(t *testing.T) { 263 | ctrl := gomock.NewController(t) 264 | defer ctrl.Finish() 265 | 266 | u := mocks.NewMockUsers(ctrl) 267 | d := mocks.NewMockDB(ctrl) 268 | 269 | ht := httptransport.New(u, d) 270 | require.NotNil(t, ht) 271 | 272 | r := mux.NewRouter() 273 | 274 | err := ht.AddRoutes(r) 275 | require.NoError(t, err) 276 | 277 | w := httptest.NewRecorder() 278 | 279 | u.EXPECT().GetUser(gomock.Any(), tt.args.id).Return(nil, errors.New(tt.wantErr)).Times(1) 280 | 281 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(userURL, tt.args.id), nil) 282 | require.NoError(t, err) 283 | 284 | r.ServeHTTP(w, req) 285 | 286 | assert.Equal(t, tt.wantCode, w.Code) 287 | 288 | var res struct { 289 | Error string `json:"error"` 290 | } 291 | 292 | err = json.Unmarshal(w.Body.Bytes(), &res) 293 | require.NoError(t, err) 294 | assert.Equal(t, tt.wantErr, res.Error) 295 | }) 296 | } 297 | } 298 | 299 | func TestServer_SearchUsers_Success(t *testing.T) { 300 | type args struct { 301 | filters []model.Filter 302 | offset int64 303 | limit int64 304 | } 305 | tests := []struct { 306 | name string 307 | args args 308 | wantUsers []*model.User 309 | wantCode int 310 | }{ 311 | { 312 | name: "success", 313 | args: args{ 314 | filters: []model.Filter{ 315 | { 316 | MatchType: model.MatchTypeEqual, 317 | Field: model.FieldCountry, 318 | Value: "UK", 319 | }, 320 | }, 321 | }, 322 | wantUsers: []*model.User{ 323 | { 324 | ID: pointer.ToString("some-test-id"), 325 | FirstName: pointer.ToString("testFirst"), 326 | LastName: pointer.ToString("testLast"), 327 | Nickname: pointer.ToString("test"), 328 | Password: pointer.ToString("test"), 329 | Email: pointer.ToString("test@test.com"), 330 | Country: pointer.ToString("UK"), 331 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 332 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 333 | }, 334 | }, 335 | wantCode: http.StatusOK, 336 | }, 337 | } 338 | for _, tt := range tests { 339 | t.Run(tt.name, func(t *testing.T) { 340 | ctrl := gomock.NewController(t) 341 | defer ctrl.Finish() 342 | 343 | u := mocks.NewMockUsers(ctrl) 344 | d := mocks.NewMockDB(ctrl) 345 | 346 | ht := httptransport.New(u, d) 347 | require.NotNil(t, ht) 348 | 349 | r := mux.NewRouter() 350 | 351 | err := ht.AddRoutes(r) 352 | require.NoError(t, err) 353 | 354 | w := httptest.NewRecorder() 355 | 356 | u.EXPECT().FindUsers(gomock.Any(), tt.args.filters, tt.args.offset, tt.args.limit).Return(tt.wantUsers, nil).Times(1) 357 | 358 | data, err := json.Marshal(httptransport.SearchUsersRequest{Filters: tt.args.filters, Offset: tt.args.offset, Limit: tt.args.limit}) 359 | require.NoError(t, err) 360 | require.NotNil(t, data) 361 | 362 | req, err := http.NewRequest(http.MethodPost, searchURL, bytes.NewBuffer(data)) 363 | require.NoError(t, err) 364 | 365 | r.ServeHTTP(w, req) 366 | 367 | assert.Equal(t, tt.wantCode, w.Code) 368 | 369 | var res struct { 370 | Data []*model.User `json:"data"` 371 | } 372 | 373 | err = json.Unmarshal(w.Body.Bytes(), &res) 374 | require.NoError(t, err) 375 | assert.EqualValues(t, tt.wantUsers, res.Data) 376 | }) 377 | } 378 | } 379 | 380 | func TestServer_SearchUsers_Error(t *testing.T) { 381 | type args struct { 382 | filters []model.Filter 383 | offset int64 384 | limit int64 385 | } 386 | tests := []struct { 387 | name string 388 | args args 389 | wantErr string 390 | wantCode int 391 | }{ 392 | { 393 | name: "success", 394 | args: args{ 395 | filters: []model.Filter{ 396 | { 397 | MatchType: model.MatchTypeEqual, 398 | Field: model.FieldCountry, 399 | Value: "UK", 400 | }, 401 | }, 402 | }, 403 | wantErr: "test fail", 404 | wantCode: http.StatusInternalServerError, 405 | }, 406 | } 407 | for _, tt := range tests { 408 | t.Run(tt.name, func(t *testing.T) { 409 | ctrl := gomock.NewController(t) 410 | defer ctrl.Finish() 411 | 412 | u := mocks.NewMockUsers(ctrl) 413 | d := mocks.NewMockDB(ctrl) 414 | 415 | ht := httptransport.New(u, d) 416 | require.NotNil(t, ht) 417 | 418 | r := mux.NewRouter() 419 | 420 | err := ht.AddRoutes(r) 421 | require.NoError(t, err) 422 | 423 | w := httptest.NewRecorder() 424 | 425 | u.EXPECT().FindUsers(gomock.Any(), tt.args.filters, tt.args.offset, tt.args.limit).Return(nil, errors.New(tt.wantErr)).Times(1) 426 | 427 | data, err := json.Marshal(httptransport.SearchUsersRequest{Filters: tt.args.filters, Offset: tt.args.offset, Limit: tt.args.limit}) 428 | require.NoError(t, err) 429 | require.NotNil(t, data) 430 | 431 | req, err := http.NewRequest(http.MethodPost, searchURL, bytes.NewBuffer(data)) 432 | require.NoError(t, err) 433 | 434 | r.ServeHTTP(w, req) 435 | 436 | assert.Equal(t, tt.wantCode, w.Code) 437 | 438 | var res struct { 439 | Error string `json:"error"` 440 | } 441 | 442 | err = json.Unmarshal(w.Body.Bytes(), &res) 443 | require.NoError(t, err) 444 | assert.Equal(t, tt.wantErr, res.Error) 445 | }) 446 | } 447 | } 448 | 449 | func TestServer_UpdateUser_Success(t *testing.T) { 450 | type args struct { 451 | user model.User 452 | } 453 | tests := []struct { 454 | name string 455 | args args 456 | wantUser *model.User 457 | wantCode int 458 | }{ 459 | { 460 | name: "success", 461 | args: args{ 462 | user: model.User{ 463 | ID: pointer.ToString("some-test-id"), 464 | FirstName: pointer.ToString("testFirst"), 465 | LastName: pointer.ToString("testLast"), 466 | Nickname: pointer.ToString("test"), 467 | Password: pointer.ToString("test"), 468 | Email: pointer.ToString("test@test.com"), 469 | Country: pointer.ToString("UK"), 470 | }, 471 | }, 472 | wantUser: &model.User{ 473 | ID: pointer.ToString("some-test-id"), 474 | FirstName: pointer.ToString("testFirst"), 475 | LastName: pointer.ToString("testLast"), 476 | Nickname: pointer.ToString("test"), 477 | Password: pointer.ToString("test"), 478 | Email: pointer.ToString("test@test.com"), 479 | Country: pointer.ToString("UK"), 480 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 481 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 482 | }, 483 | wantCode: http.StatusOK, 484 | }, 485 | } 486 | for _, tt := range tests { 487 | t.Run(tt.name, func(t *testing.T) { 488 | ctrl := gomock.NewController(t) 489 | defer ctrl.Finish() 490 | 491 | u := mocks.NewMockUsers(ctrl) 492 | d := mocks.NewMockDB(ctrl) 493 | 494 | ht := httptransport.New(u, d) 495 | require.NotNil(t, ht) 496 | 497 | r := mux.NewRouter() 498 | 499 | err := ht.AddRoutes(r) 500 | require.NoError(t, err) 501 | 502 | w := httptest.NewRecorder() 503 | 504 | u.EXPECT().UpdateUser(gomock.Any(), &tt.args.user).Return(tt.wantUser, nil).Times(1) 505 | 506 | data, err := json.Marshal(tt.args.user) 507 | require.NoError(t, err) 508 | require.NotNil(t, data) 509 | 510 | req, err := http.NewRequest(http.MethodPut, fmt.Sprintf(userURL, *tt.args.user.ID), bytes.NewBuffer(data)) 511 | require.NoError(t, err) 512 | 513 | r.ServeHTTP(w, req) 514 | 515 | assert.Equal(t, tt.wantCode, w.Code) 516 | 517 | var res struct { 518 | Data *model.User `json:"data"` 519 | } 520 | 521 | err = json.Unmarshal(w.Body.Bytes(), &res) 522 | require.NoError(t, err) 523 | assert.Equal(t, tt.wantUser, res.Data) 524 | }) 525 | } 526 | } 527 | 528 | func TestServer_UpdateUser_Error(t *testing.T) { 529 | type args struct { 530 | user model.User 531 | } 532 | tests := []struct { 533 | name string 534 | args args 535 | wantErr string 536 | wantCode int 537 | }{ 538 | { 539 | name: "fails", 540 | args: args{ 541 | user: model.User{ 542 | ID: pointer.ToString("some-test-id"), 543 | FirstName: pointer.ToString("testFirst"), 544 | LastName: pointer.ToString("testLast"), 545 | Nickname: pointer.ToString("test"), 546 | Password: pointer.ToString("test"), 547 | Email: pointer.ToString("test@test.com"), 548 | Country: pointer.ToString("UK"), 549 | }, 550 | }, 551 | wantErr: "test fail", 552 | wantCode: http.StatusInternalServerError, 553 | }, 554 | } 555 | for _, tt := range tests { 556 | t.Run(tt.name, func(t *testing.T) { 557 | ctrl := gomock.NewController(t) 558 | defer ctrl.Finish() 559 | 560 | u := mocks.NewMockUsers(ctrl) 561 | d := mocks.NewMockDB(ctrl) 562 | 563 | ht := httptransport.New(u, d) 564 | require.NotNil(t, ht) 565 | 566 | r := mux.NewRouter() 567 | 568 | err := ht.AddRoutes(r) 569 | require.NoError(t, err) 570 | 571 | w := httptest.NewRecorder() 572 | 573 | u.EXPECT().UpdateUser(gomock.Any(), &tt.args.user).Return(nil, errors.New(tt.wantErr)).Times(1) 574 | 575 | data, err := json.Marshal(tt.args.user) 576 | require.NoError(t, err) 577 | require.NotNil(t, data) 578 | 579 | req, err := http.NewRequest(http.MethodPut, fmt.Sprintf(userURL, *tt.args.user.ID), bytes.NewBuffer(data)) 580 | require.NoError(t, err) 581 | 582 | r.ServeHTTP(w, req) 583 | 584 | assert.Equal(t, tt.wantCode, w.Code) 585 | 586 | var res struct { 587 | Error string `json:"error"` 588 | } 589 | 590 | err = json.Unmarshal(w.Body.Bytes(), &res) 591 | require.NoError(t, err) 592 | assert.Equal(t, tt.wantErr, res.Error) 593 | }) 594 | } 595 | } 596 | 597 | func TestServer_DeleteUser_Success(t *testing.T) { 598 | type args struct { 599 | id string 600 | } 601 | tests := []struct { 602 | name string 603 | args args 604 | wantSuccess bool 605 | wantCode int 606 | }{ 607 | { 608 | name: "success", 609 | args: args{ 610 | id: "some-test-id", 611 | }, 612 | wantSuccess: true, 613 | wantCode: http.StatusOK, 614 | }, 615 | } 616 | for _, tt := range tests { 617 | t.Run(tt.name, func(t *testing.T) { 618 | ctrl := gomock.NewController(t) 619 | defer ctrl.Finish() 620 | 621 | u := mocks.NewMockUsers(ctrl) 622 | d := mocks.NewMockDB(ctrl) 623 | 624 | ht := httptransport.New(u, d) 625 | require.NotNil(t, ht) 626 | 627 | r := mux.NewRouter() 628 | 629 | err := ht.AddRoutes(r) 630 | require.NoError(t, err) 631 | 632 | w := httptest.NewRecorder() 633 | 634 | u.EXPECT().DeleteUser(gomock.Any(), tt.args.id).Return(nil).Times(1) 635 | 636 | req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf(userURL, tt.args.id), nil) 637 | require.NoError(t, err) 638 | 639 | r.ServeHTTP(w, req) 640 | 641 | assert.Equal(t, tt.wantCode, w.Code) 642 | 643 | var res struct { 644 | Data httptransport.DeletedUserResponse `json:"data"` 645 | } 646 | 647 | err = json.Unmarshal(w.Body.Bytes(), &res) 648 | require.NoError(t, err) 649 | assert.Equal(t, tt.wantSuccess, res.Data.Success) 650 | }) 651 | } 652 | } 653 | 654 | func TestServer_DeleteUser_Error(t *testing.T) { 655 | type args struct { 656 | id string 657 | } 658 | tests := []struct { 659 | name string 660 | args args 661 | wantErr string 662 | wantCode int 663 | }{ 664 | { 665 | name: "fails", 666 | args: args{ 667 | id: "some-test-id", 668 | }, 669 | wantErr: "test fail", 670 | wantCode: http.StatusInternalServerError, 671 | }, 672 | } 673 | for _, tt := range tests { 674 | t.Run(tt.name, func(t *testing.T) { 675 | ctrl := gomock.NewController(t) 676 | defer ctrl.Finish() 677 | 678 | u := mocks.NewMockUsers(ctrl) 679 | d := mocks.NewMockDB(ctrl) 680 | 681 | ht := httptransport.New(u, d) 682 | require.NotNil(t, ht) 683 | 684 | r := mux.NewRouter() 685 | 686 | err := ht.AddRoutes(r) 687 | require.NoError(t, err) 688 | 689 | w := httptest.NewRecorder() 690 | 691 | u.EXPECT().DeleteUser(gomock.Any(), tt.args.id).Return(errors.New(tt.wantErr)).Times(1) 692 | 693 | req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf(userURL, tt.args.id), nil) 694 | require.NoError(t, err) 695 | 696 | r.ServeHTTP(w, req) 697 | 698 | assert.Equal(t, tt.wantCode, w.Code) 699 | 700 | var res struct { 701 | Error string `json:"error"` 702 | } 703 | 704 | err = json.Unmarshal(w.Body.Bytes(), &res) 705 | require.NoError(t, err) 706 | assert.Equal(t, tt.wantErr, res.Error) 707 | }) 708 | } 709 | } 710 | -------------------------------------------------------------------------------- /internal/transport/http/users.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 11 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type searchUsersRequest struct { 16 | Filters []model.Filter `json:"filters"` 17 | Offset int64 `json:"offset"` 18 | Limit int64 `json:"limit"` 19 | } 20 | 21 | type deletedUserResponse struct { 22 | Success bool `json:"success"` 23 | } 24 | 25 | func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { 26 | ctx := r.Context() 27 | 28 | w.Header().Add("Content-Type", "application/json") // TODO might do this in application specific middleware instead 29 | 30 | data, err := io.ReadAll(r.Body) 31 | if err != nil { 32 | logging.From(ctx).Error("failed to read request body", zap.Error(err)) 33 | handleError(ctx, w, errors.ErrUnknown.Wrap(err)) 34 | return 35 | } 36 | 37 | u := model.User{} 38 | 39 | if err := json.Unmarshal(data, &u); err != nil { 40 | logging.From(ctx).Error("failed to unmarshal json body", zap.Error(err)) 41 | handleError(ctx, w, errors.ErrInvalidRequest.Wrap(err)) 42 | return 43 | } 44 | 45 | createdUser, err := s.users.CreateUser(ctx, &u) 46 | if err != nil { 47 | // TODO deal with different error types that affect the error response from the generic types 48 | logging.From(ctx).Error("failed to create user", zap.Error(err)) 49 | handleError(ctx, w, err) 50 | return 51 | } 52 | 53 | handleResponse(ctx, w, createdUser) 54 | } 55 | 56 | func (s *Server) getUser(w http.ResponseWriter, r *http.Request) { 57 | ctx := r.Context() 58 | 59 | w.Header().Add("Content-Type", "application/json") // TODO might do this in application specific middleware instead 60 | 61 | vars := mux.Vars(r) 62 | id := vars["id"] 63 | 64 | u, err := s.users.GetUser(ctx, id) 65 | if err != nil { 66 | logging.From(ctx).Error("failed to get user", zap.Error(err)) 67 | handleError(ctx, w, err) 68 | return 69 | } 70 | 71 | handleResponse(ctx, w, u) 72 | } 73 | 74 | func (s *Server) searchUsers(w http.ResponseWriter, r *http.Request) { 75 | ctx := r.Context() 76 | 77 | w.Header().Add("Content-Type", "application/json") // TODO might do this in application specific middleware instead 78 | 79 | data, err := io.ReadAll(r.Body) 80 | if err != nil { 81 | logging.From(ctx).Error("failed to read request body", zap.Error(err)) 82 | handleError(ctx, w, errors.ErrUnknown.Wrap(err)) 83 | return 84 | } 85 | 86 | req := searchUsersRequest{} 87 | 88 | if err := json.Unmarshal(data, &req); err != nil { 89 | logging.From(ctx).Error("failed to unmarshal json body", zap.Error(err)) 90 | handleError(ctx, w, errors.ErrInvalidRequest.Wrap(err)) 91 | return 92 | } 93 | 94 | users, err := s.users.FindUsers(ctx, req.Filters, req.Offset, req.Limit) 95 | if err != nil { 96 | logging.From(ctx).Error("failed to find users", zap.Error(err)) 97 | handleError(ctx, w, err) 98 | return 99 | } 100 | 101 | handleResponse(ctx, w, users) 102 | } 103 | 104 | func (s *Server) updateUser(w http.ResponseWriter, r *http.Request) { 105 | ctx := r.Context() 106 | 107 | w.Header().Add("Content-Type", "application/json") 108 | 109 | vars := mux.Vars(r) 110 | id := vars["id"] 111 | 112 | data, err := io.ReadAll(r.Body) 113 | if err != nil { 114 | logging.From(ctx).Error("failed to read request body", zap.Error(err)) 115 | handleError(ctx, w, errors.ErrUnknown.Wrap(err)) 116 | return 117 | } 118 | 119 | u := model.User{} 120 | 121 | if err := json.Unmarshal(data, &u); err != nil { 122 | logging.From(ctx).Error("failed to unmarshal json body", zap.Error(err)) 123 | handleError(ctx, w, errors.ErrInvalidRequest.Wrap(err)) 124 | return 125 | } 126 | 127 | u.ID = &id 128 | 129 | updateUser, err := s.users.UpdateUser(ctx, &u) 130 | if err != nil { 131 | logging.From(ctx).Error("failed to update user", zap.Error(err)) 132 | handleError(ctx, w, err) 133 | return 134 | } 135 | 136 | handleResponse(ctx, w, updateUser) 137 | } 138 | 139 | func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { 140 | ctx := r.Context() 141 | 142 | w.Header().Add("Content-Type", "application/json") 143 | 144 | vars := mux.Vars(r) 145 | id := vars["id"] 146 | 147 | if err := s.users.DeleteUser(ctx, id); err != nil { 148 | logging.From(ctx).Error("failed to delete user", zap.Error(err)) 149 | handleError(ctx, w, err) 150 | return 151 | } 152 | 153 | handleResponse(ctx, w, deletedUserResponse{Success: true}) 154 | } 155 | -------------------------------------------------------------------------------- /internal/transport/http/users_exports_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | type ( 4 | SearchUsersRequest searchUsersRequest 5 | DeletedUserResponse deletedUserResponse 6 | ) 7 | -------------------------------------------------------------------------------- /internal/users/mocks/users_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/speakeasy-api/rest-template-go/internal/users (interfaces: Store,Events) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | events "github.com/speakeasy-api/rest-template-go/internal/events" 10 | model "github.com/speakeasy-api/rest-template-go/internal/users/model" 11 | reflect "reflect" 12 | 13 | gomock "github.com/golang/mock/gomock" 14 | ) 15 | 16 | // MockStore is a mock of Store interface. 17 | type MockStore struct { 18 | ctrl *gomock.Controller 19 | recorder *MockStoreMockRecorder 20 | } 21 | 22 | // MockStoreMockRecorder is the mock recorder for MockStore. 23 | type MockStoreMockRecorder struct { 24 | mock *MockStore 25 | } 26 | 27 | // NewMockStore creates a new mock instance. 28 | func NewMockStore(ctrl *gomock.Controller) *MockStore { 29 | mock := &MockStore{ctrl: ctrl} 30 | mock.recorder = &MockStoreMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockStore) EXPECT() *MockStoreMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // DeleteUser mocks base method. 40 | func (m *MockStore) DeleteUser(arg0 context.Context, arg1 string) error { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "DeleteUser", arg0, arg1) 43 | ret0, _ := ret[0].(error) 44 | return ret0 45 | } 46 | 47 | // DeleteUser indicates an expected call of DeleteUser. 48 | func (mr *MockStoreMockRecorder) DeleteUser(arg0, arg1 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockStore)(nil).DeleteUser), arg0, arg1) 51 | } 52 | 53 | // FindUsers mocks base method. 54 | func (m *MockStore) FindUsers(arg0 context.Context, arg1 []model.Filter, arg2, arg3 int64) ([]*model.User, error) { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "FindUsers", arg0, arg1, arg2, arg3) 57 | ret0, _ := ret[0].([]*model.User) 58 | ret1, _ := ret[1].(error) 59 | return ret0, ret1 60 | } 61 | 62 | // FindUsers indicates an expected call of FindUsers. 63 | func (mr *MockStoreMockRecorder) FindUsers(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUsers", reflect.TypeOf((*MockStore)(nil).FindUsers), arg0, arg1, arg2, arg3) 66 | } 67 | 68 | // GetUser mocks base method. 69 | func (m *MockStore) GetUser(arg0 context.Context, arg1 string) (*model.User, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetUser", arg0, arg1) 72 | ret0, _ := ret[0].(*model.User) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetUser indicates an expected call of GetUser. 78 | func (mr *MockStoreMockRecorder) GetUser(arg0, arg1 interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockStore)(nil).GetUser), arg0, arg1) 81 | } 82 | 83 | // GetUserByEmail mocks base method. 84 | func (m *MockStore) GetUserByEmail(arg0 context.Context, arg1 string) (*model.User, error) { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "GetUserByEmail", arg0, arg1) 87 | ret0, _ := ret[0].(*model.User) 88 | ret1, _ := ret[1].(error) 89 | return ret0, ret1 90 | } 91 | 92 | // GetUserByEmail indicates an expected call of GetUserByEmail. 93 | func (mr *MockStoreMockRecorder) GetUserByEmail(arg0, arg1 interface{}) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockStore)(nil).GetUserByEmail), arg0, arg1) 96 | } 97 | 98 | // InsertUser mocks base method. 99 | func (m *MockStore) InsertUser(arg0 context.Context, arg1 *model.User) (*model.User, error) { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "InsertUser", arg0, arg1) 102 | ret0, _ := ret[0].(*model.User) 103 | ret1, _ := ret[1].(error) 104 | return ret0, ret1 105 | } 106 | 107 | // InsertUser indicates an expected call of InsertUser. 108 | func (mr *MockStoreMockRecorder) InsertUser(arg0, arg1 interface{}) *gomock.Call { 109 | mr.mock.ctrl.T.Helper() 110 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUser", reflect.TypeOf((*MockStore)(nil).InsertUser), arg0, arg1) 111 | } 112 | 113 | // UpdateUser mocks base method. 114 | func (m *MockStore) UpdateUser(arg0 context.Context, arg1 *model.User) (*model.User, error) { 115 | m.ctrl.T.Helper() 116 | ret := m.ctrl.Call(m, "UpdateUser", arg0, arg1) 117 | ret0, _ := ret[0].(*model.User) 118 | ret1, _ := ret[1].(error) 119 | return ret0, ret1 120 | } 121 | 122 | // UpdateUser indicates an expected call of UpdateUser. 123 | func (mr *MockStoreMockRecorder) UpdateUser(arg0, arg1 interface{}) *gomock.Call { 124 | mr.mock.ctrl.T.Helper() 125 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockStore)(nil).UpdateUser), arg0, arg1) 126 | } 127 | 128 | // MockEvents is a mock of Events interface. 129 | type MockEvents struct { 130 | ctrl *gomock.Controller 131 | recorder *MockEventsMockRecorder 132 | } 133 | 134 | // MockEventsMockRecorder is the mock recorder for MockEvents. 135 | type MockEventsMockRecorder struct { 136 | mock *MockEvents 137 | } 138 | 139 | // NewMockEvents creates a new mock instance. 140 | func NewMockEvents(ctrl *gomock.Controller) *MockEvents { 141 | mock := &MockEvents{ctrl: ctrl} 142 | mock.recorder = &MockEventsMockRecorder{mock} 143 | return mock 144 | } 145 | 146 | // EXPECT returns an object that allows the caller to indicate expected use. 147 | func (m *MockEvents) EXPECT() *MockEventsMockRecorder { 148 | return m.recorder 149 | } 150 | 151 | // Produce mocks base method. 152 | func (m *MockEvents) Produce(arg0 context.Context, arg1 events.Topic, arg2 interface{}) { 153 | m.ctrl.T.Helper() 154 | m.ctrl.Call(m, "Produce", arg0, arg1, arg2) 155 | } 156 | 157 | // Produce indicates an expected call of Produce. 158 | func (mr *MockEventsMockRecorder) Produce(arg0, arg1, arg2 interface{}) *gomock.Call { 159 | mr.mock.ctrl.T.Helper() 160 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Produce", reflect.TypeOf((*MockEvents)(nil).Produce), arg0, arg1, arg2) 161 | } 162 | -------------------------------------------------------------------------------- /internal/users/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // User represents a person using our platform. 6 | type User struct { 7 | ID *string `json:"id" db:"id"` 8 | FirstName *string `json:"first_name" db:"first_name"` 9 | LastName *string `json:"last_name" db:"last_name"` 10 | Nickname *string `json:"nickname" db:"nickname"` 11 | Password *string `json:"password" db:"password"` 12 | Email *string `json:"email" db:"email"` 13 | Country *string `json:"country" db:"country"` 14 | CreatedAt *time.Time `json:"created_at" db:"created_at"` 15 | UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` 16 | } 17 | 18 | // Field is an enum providing valid fields for filtering. 19 | type Field string 20 | 21 | const ( 22 | // FieldFirstName represents the first name field. 23 | FieldFirstName Field = "first_name" 24 | // FieldLastName represents the last name field. 25 | FieldLastName Field = "last_name" 26 | // FieldNickname represents the nickname field. 27 | FieldNickname Field = "nickname" 28 | // FieldEmail represents the email field. 29 | FieldEmail Field = "email" 30 | // FieldCountry represents the country field. 31 | FieldCountry Field = "country" 32 | ) 33 | 34 | // MatchType is an enum providing valid matching mechanisms for filtering values. 35 | type MatchType string 36 | 37 | const ( 38 | // MatchTypeLike represents a LIKE match. 39 | MatchTypeLike MatchType = "ILIKE" 40 | // MatchTypeEqual represents an exact match. 41 | MatchTypeEqual MatchType = "=" 42 | ) 43 | 44 | // Filter is a struct representing a filter for finding users. 45 | type Filter struct { 46 | MatchType MatchType `json:"match_type"` 47 | Field Field `json:"field"` 48 | Value string `json:"value"` 49 | } 50 | -------------------------------------------------------------------------------- /internal/users/store/findusers.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 11 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // FindUsers will retrieve a list of users based on matching all of the the provided filters and using pagination if limit is gt 0 16 | // Note: depending on the actual use cases for such functionality I would probably take the route of using elasticsearch and opening up 17 | // the flexibility of having a search type function. 18 | func (s *Store) FindUsers(ctx context.Context, filters []model.Filter, offset, limit int64) ([]*model.User, error) { 19 | if len(filters) == 0 { 20 | return nil, ErrInvalidFilters.Wrap(errors.ErrInvalidRequest) 21 | } 22 | 23 | whereClauses := []string{} 24 | values := []interface{}{} 25 | 26 | for i, f := range filters { 27 | whereClauses = append(whereClauses, fmt.Sprintf("%s %s $%d", f.Field, f.MatchType, i+1)) 28 | values = append(values, getFindValue(f)) 29 | } 30 | 31 | limitClause := "" 32 | 33 | if limit > 0 { 34 | limitClause = fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset) 35 | } 36 | 37 | rows, err := s.db.QueryxContext(ctx, fmt.Sprintf("SELECT * FROM users WHERE %s ORDER BY id ASC%s", strings.Join(whereClauses, " AND "), limitClause), values...) 38 | if err != nil { 39 | if errors.Is(err, sql.ErrNoRows) { 40 | return nil, errors.ErrNotFound.Wrap(err) 41 | } 42 | 43 | return nil, errors.ErrUnknown.Wrap(err) 44 | } 45 | if rows == nil { 46 | return nil, errors.ErrUnknown 47 | } 48 | defer rows.Close() 49 | 50 | users := []*model.User{} 51 | 52 | for rows.Next() { 53 | var u model.User 54 | if err := rows.StructScan(&u); err != nil { 55 | logging.From(ctx).Error("failed to deserialize user from database", zap.Error(err)) 56 | } else { 57 | users = append(users, &u) 58 | } 59 | } 60 | 61 | if len(users) == 0 { 62 | return nil, errors.ErrNotFound 63 | } 64 | 65 | return users, nil 66 | } 67 | 68 | func getFindValue(f model.Filter) string { 69 | switch f.MatchType { 70 | case model.MatchTypeLike: 71 | return fmt.Sprintf("%%%s%%", f.Value) 72 | case model.MatchTypeEqual: 73 | fallthrough 74 | default: 75 | return f.Value 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/users/store/findusers_integration_test.go: -------------------------------------------------------------------------------- 1 | package store_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/AlekSi/pointer" 9 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 10 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 11 | "github.com/speakeasy-api/rest-template-go/internal/users/store" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestStore_FindUsers_Success(t *testing.T) { 17 | countries := []string{"UK", "IT", "US"} 18 | nicknames := []string{"nicky", "nicker", "nicksy", "nick"} 19 | firstNames := []string{"john", "judy", "jacob", "jacky", "jane"} 20 | lastNames := []string{"Smith", "Johnson", "Williams", "Brown", "Lee", "Clark"} 21 | domains := []string{"gmail.com", "hotmail.com", "yahoo.com"} 22 | 23 | for i := 0; i < 100; i++ { 24 | u := &model.User{ 25 | FirstName: pointer.ToString(fmt.Sprintf("%s%d", firstNames[i%len(firstNames)], i)), 26 | LastName: pointer.ToString(fmt.Sprintf("%s%d", lastNames[i%len(lastNames)], i)), 27 | Nickname: pointer.ToString(fmt.Sprintf("%s%d", nicknames[i%len(nicknames)], i)), 28 | Password: pointer.ToString("test"), 29 | Email: pointer.ToString(fmt.Sprintf("%s%d@%s", firstNames[i%len(firstNames)], i, domains[i%len(domains)])), 30 | Country: pointer.ToString(countries[i%len(countries)]), 31 | } 32 | 33 | _, err := insertUser(context.Background(), u) 34 | require.NoError(t, err) 35 | } 36 | 37 | type args struct { 38 | filters []model.Filter 39 | offset int64 40 | limit int64 41 | } 42 | tests := []struct { 43 | name string 44 | args args 45 | wantUserCount int 46 | }{ 47 | { 48 | name: "get all UK users using equal match", 49 | args: args{ 50 | filters: []model.Filter{ 51 | { 52 | Field: model.FieldCountry, 53 | MatchType: model.MatchTypeEqual, 54 | Value: "UK", 55 | }, 56 | }, 57 | offset: 0, 58 | limit: 0, 59 | }, 60 | wantUserCount: 35, 61 | }, 62 | { 63 | name: "get all UK users using like match", 64 | args: args{ 65 | filters: []model.Filter{ 66 | { 67 | Field: model.FieldCountry, 68 | MatchType: model.MatchTypeLike, 69 | Value: "UK", 70 | }, 71 | }, 72 | offset: 0, 73 | limit: 0, 74 | }, 75 | wantUserCount: 35, 76 | }, 77 | { 78 | name: "get first page of all UK users using equal match", 79 | args: args{ 80 | filters: []model.Filter{ 81 | { 82 | Field: model.FieldCountry, 83 | MatchType: model.MatchTypeEqual, 84 | Value: "UK", 85 | }, 86 | }, 87 | offset: 0, 88 | limit: 10, 89 | }, 90 | wantUserCount: 10, 91 | }, 92 | { 93 | name: "get last page of all UK users using equal match", 94 | args: args{ 95 | filters: []model.Filter{ 96 | { 97 | Field: model.FieldCountry, 98 | MatchType: model.MatchTypeEqual, 99 | Value: "UK", 100 | }, 101 | }, 102 | offset: 30, 103 | limit: 10, 104 | }, 105 | wantUserCount: 5, 106 | }, 107 | { 108 | name: "get all users with a first name like john", 109 | args: args{ 110 | filters: []model.Filter{ 111 | { 112 | Field: model.FieldFirstName, 113 | MatchType: model.MatchTypeLike, 114 | Value: "john", 115 | }, 116 | }, 117 | offset: 0, 118 | limit: 0, 119 | }, 120 | wantUserCount: 20, 121 | }, 122 | { 123 | name: "get all users with a first name like john in the UK", 124 | args: args{ 125 | filters: []model.Filter{ 126 | { 127 | Field: model.FieldFirstName, 128 | MatchType: model.MatchTypeLike, 129 | Value: "john", 130 | }, 131 | { 132 | Field: model.FieldCountry, 133 | MatchType: model.MatchTypeEqual, 134 | Value: "UK", 135 | }, 136 | }, 137 | offset: 0, 138 | limit: 0, 139 | }, 140 | wantUserCount: 7, 141 | }, 142 | { 143 | name: "get all users with a last name like Smith", 144 | args: args{ 145 | filters: []model.Filter{ 146 | { 147 | Field: model.FieldLastName, 148 | MatchType: model.MatchTypeLike, 149 | Value: "Smith", 150 | }, 151 | }, 152 | offset: 0, 153 | limit: 0, 154 | }, 155 | wantUserCount: 17, 156 | }, 157 | { 158 | name: "get all users with a last name like smith insensitive", 159 | args: args{ 160 | filters: []model.Filter{ 161 | { 162 | Field: model.FieldLastName, 163 | MatchType: model.MatchTypeLike, 164 | Value: "smith", 165 | }, 166 | }, 167 | offset: 0, 168 | limit: 0, 169 | }, 170 | wantUserCount: 17, 171 | }, 172 | { 173 | name: "get all users with a nickname like nicksy", 174 | args: args{ 175 | filters: []model.Filter{ 176 | { 177 | Field: model.FieldNickname, 178 | MatchType: model.MatchTypeLike, 179 | Value: "nicksy", 180 | }, 181 | }, 182 | offset: 0, 183 | limit: 0, 184 | }, 185 | wantUserCount: 25, 186 | }, 187 | { 188 | name: "get all users with an email like hotmail.com", 189 | args: args{ 190 | filters: []model.Filter{ 191 | { 192 | Field: model.FieldEmail, 193 | MatchType: model.MatchTypeLike, 194 | Value: "hotmail.com", 195 | }, 196 | }, 197 | offset: 0, 198 | limit: 0, 199 | }, 200 | wantUserCount: 33, 201 | }, 202 | } 203 | for _, tt := range tests { 204 | t.Run(tt.name, func(t *testing.T) { 205 | s := store.New(db.GetDB()) 206 | 207 | ctx := context.Background() 208 | 209 | users, err := s.FindUsers(ctx, tt.args.filters, tt.args.offset, tt.args.limit) 210 | assert.NoError(t, err) 211 | assert.NotNil(t, users) 212 | assert.Len(t, users, tt.wantUserCount) 213 | }) 214 | } 215 | } 216 | 217 | func TestStore_FindUsers_Error(t *testing.T) { 218 | type args struct { 219 | filters []model.Filter 220 | offset int64 221 | limit int64 222 | } 223 | tests := []struct { 224 | name string 225 | args args 226 | wantErr1 error 227 | wantErr2 error 228 | }{ 229 | { 230 | name: "fails with no filters", 231 | args: args{ 232 | filters: []model.Filter{}, 233 | offset: 0, 234 | limit: 0, 235 | }, 236 | wantErr1: errors.ErrInvalidRequest, 237 | wantErr2: store.ErrInvalidFilters, 238 | }, 239 | { 240 | name: "fails with no users found", 241 | args: args{ 242 | filters: []model.Filter{ 243 | { 244 | Field: model.FieldNickname, 245 | MatchType: model.MatchTypeLike, 246 | Value: "blah", 247 | }, 248 | }, 249 | offset: 0, 250 | limit: 0, 251 | }, 252 | wantErr1: errors.ErrNotFound, 253 | wantErr2: errors.ErrNotFound, 254 | }, 255 | } 256 | for _, tt := range tests { 257 | t.Run(tt.name, func(t *testing.T) { 258 | s := store.New(db.GetDB()) 259 | 260 | ctx := context.Background() 261 | 262 | users, err := s.FindUsers(ctx, tt.args.filters, tt.args.offset, tt.args.limit) 263 | assert.ErrorIs(t, err, tt.wantErr1) 264 | assert.ErrorIs(t, err, tt.wantErr2) 265 | assert.Nil(t, users) 266 | }) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /internal/users/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strings" 7 | "time" 8 | 9 | "github.com/jmoiron/sqlx" 10 | "github.com/lib/pq" 11 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 12 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 13 | ) 14 | 15 | const ( 16 | // ErrInvalidEmail is returned when the email is not a valid address or is empty. 17 | ErrInvalidEmail = errors.Error("invalid_email: email is invalid") 18 | // ErrEmailAlreadyUsed is returned when the email address is already used via another user. 19 | ErrEmailAlreadyUsed = errors.Error("email_already_used: email is already in use") 20 | // ErrEmptyNickname is returned when the nickname is empty. 21 | ErrEmptyNickname = errors.Error("empty_nickname: nickname is empty") 22 | // ErrNicknameAlreadyUsed is returned when the nickname is already used via another user. 23 | ErrNicknameAlreadyUsed = errors.Error("nickname_already_used: nickname is already in use") 24 | // ErrEmptyPassword is returned when the password is empty. 25 | ErrEmptyPassword = errors.Error("empty_password: password is empty") 26 | // ErrEmptyCountry is returned when the country is empty. 27 | ErrEmptyCountry = errors.Error("empty_country: password is empty") 28 | // ErrInvalidID si returned when the ID is not a valid UUID or is empty. 29 | ErrInvalidID = errors.Error("invalid_id: id is invalid") 30 | // ErrUserNotUpdated is returned when a record can't be found to update. 31 | ErrUserNotUpdated = errors.Error("user_not_updated: user record wasn't updated") 32 | // ErrUserNotDeleted is returned when a record can't be found to delete. 33 | ErrUserNotDeleted = errors.Error("user_not_deleted: user record wasn't deleted") 34 | // ErrInvalidFilters is returned when the filters for finding a user are not valid. 35 | ErrInvalidFilters = errors.Error("invalid_filters: filters invalid for finding user") 36 | ) 37 | 38 | const ( 39 | pqErrInvalidTextRepresentation = "invalid_text_representation" 40 | ) 41 | 42 | var timeNow = func() *time.Time { 43 | now := time.Now().UTC() 44 | return &now 45 | } 46 | 47 | // DB represents a type for interfacing with a postgres database. 48 | type DB interface { 49 | NamedQueryContext(ctx context.Context, query string, arg interface{}) (*sqlx.Rows, error) 50 | GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error 51 | NamedExecContext(ctx context.Context, query string, arg interface{}) (sql.Result, error) 52 | ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 53 | QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) 54 | } 55 | 56 | // Store provides functionality for working with a postgres database. 57 | type Store struct { 58 | db DB 59 | } 60 | 61 | // New will instantiate a new instance of Store. 62 | func New(db DB) *Store { 63 | return &Store{ 64 | db: db, 65 | } 66 | } 67 | 68 | // InsertUser will add a new unique user to the database using the provided data. 69 | func (s *Store) InsertUser(ctx context.Context, u *model.User) (*model.User, error) { 70 | u.CreatedAt = timeNow() 71 | u.UpdatedAt = u.CreatedAt 72 | 73 | res, err := s.db.NamedQueryContext(ctx, 74 | `INSERT INTO 75 | users(first_name, last_name, nickname, password, email, country, created_at, updated_at) 76 | VALUES (:first_name, :last_name, :nickname, :password, :email, :country, :created_at, :updated_at) 77 | RETURNING *`, u) 78 | if err = checkWriteError(err); err != nil { 79 | return nil, err 80 | } 81 | defer res.Close() 82 | 83 | if !res.Next() { 84 | return nil, errors.ErrUnknown 85 | } 86 | 87 | createdUser := &model.User{} 88 | 89 | if err := res.StructScan(&createdUser); err != nil { 90 | return nil, errors.ErrUnknown.Wrap(err) 91 | } 92 | 93 | return createdUser, nil 94 | } 95 | 96 | // GetUser will retrieve an existing user via their ID. 97 | func (s *Store) GetUser(ctx context.Context, id string) (*model.User, error) { 98 | var u model.User 99 | 100 | if err := s.db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = $1", id); err != nil { 101 | if errors.Is(err, sql.ErrNoRows) { 102 | return nil, errors.ErrNotFound.Wrap(err) 103 | } 104 | var pqErr *pq.Error 105 | if errors.As(err, &pqErr) { 106 | if pqErr.Code.Name() == pqErrInvalidTextRepresentation && strings.Contains(pqErr.Error(), "uuid") { 107 | return nil, ErrInvalidID.Wrap(errors.ErrValidation.Wrap(err)) 108 | } 109 | } 110 | 111 | return nil, errors.ErrUnknown.Wrap(err) 112 | } 113 | 114 | return &u, nil 115 | } 116 | 117 | // GetUserByEmail will retrieve an existing user via their email address. 118 | func (s *Store) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { 119 | var u model.User 120 | 121 | if err := s.db.GetContext(ctx, &u, "SELECT * FROM users WHERE email = $1", email); err != nil { 122 | if errors.Is(err, sql.ErrNoRows) { 123 | return nil, errors.ErrNotFound.Wrap(err) 124 | } 125 | 126 | return nil, errors.ErrUnknown.Wrap(err) 127 | } 128 | 129 | return &u, nil 130 | } 131 | 132 | // UpdateUser will update an existing user in the database using only the present data provided. 133 | func (s *Store) UpdateUser(ctx context.Context, u *model.User) (*model.User, error) { 134 | if u.ID == nil || *u.ID == "" { 135 | return nil, ErrInvalidID.Wrap(errors.ErrValidation) 136 | } 137 | 138 | u.UpdatedAt = timeNow() 139 | 140 | res, err := s.db.NamedQueryContext(ctx, 141 | `UPDATE users 142 | SET 143 | first_name = COALESCE(:first_name, first_name), 144 | last_name = COALESCE(:last_name, last_name), 145 | nickname = COALESCE(:nickname, nickname), 146 | password = COALESCE(:password, password), 147 | email = COALESCE(:email, email), 148 | country = COALESCE(:country, country), 149 | updated_at = :updated_at 150 | WHERE id = :id 151 | RETURNING *`, u) 152 | if err = checkWriteError(err); err != nil { 153 | return nil, err 154 | } 155 | defer res.Close() 156 | 157 | if !res.Next() { 158 | return nil, ErrUserNotUpdated.Wrap(errors.ErrNotFound) 159 | } 160 | 161 | updatedUser := &model.User{} 162 | 163 | if err := res.StructScan(&updatedUser); err != nil { 164 | return nil, errors.ErrUnknown.Wrap(err) 165 | } 166 | 167 | return updatedUser, nil 168 | } 169 | 170 | // DeleteUser will delete an existing user via their ID. 171 | func (s *Store) DeleteUser(ctx context.Context, id string) error { 172 | res, err := s.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id) 173 | if err != nil { 174 | var pqErr *pq.Error 175 | if errors.As(err, &pqErr) { 176 | if pqErr.Code.Name() == pqErrInvalidTextRepresentation && strings.Contains(pqErr.Error(), "uuid") { 177 | return ErrInvalidID.Wrap(errors.ErrValidation.Wrap(err)) 178 | } 179 | } 180 | 181 | return errors.ErrUnknown.Wrap(err) 182 | } 183 | rows, err := res.RowsAffected() 184 | if err != nil { 185 | return errors.ErrUnknown.Wrap(err) 186 | } 187 | if rows != 1 { 188 | return ErrUserNotDeleted.Wrap(errors.ErrNotFound) 189 | } 190 | 191 | return nil 192 | } 193 | 194 | //nolint:cyclop 195 | func checkWriteError(err error) error { 196 | if err == nil { 197 | return nil 198 | } 199 | 200 | var pqErr *pq.Error 201 | if errors.As(err, &pqErr) { 202 | switch pqErr.Code.Name() { 203 | case "string_data_right_truncation": 204 | return errors.ErrValidation.Wrap(err) 205 | case "check_violation": 206 | switch { 207 | case strings.Contains(pqErr.Error(), "email_check"): 208 | return ErrInvalidEmail.Wrap(errors.ErrValidation.Wrap(err)) 209 | case strings.Contains(pqErr.Error(), "users_nickname_check"): 210 | return ErrEmptyNickname.Wrap(errors.ErrValidation.Wrap(err)) 211 | case strings.Contains(pqErr.Error(), "users_password_check"): 212 | return ErrEmptyPassword.Wrap(errors.ErrValidation.Wrap(err)) 213 | case strings.Contains(pqErr.Error(), "users_country_check"): 214 | return ErrEmptyCountry.Wrap(errors.ErrValidation.Wrap(err)) 215 | default: 216 | return errors.ErrValidation.Wrap(err) 217 | } 218 | case "not_null_violation": 219 | switch { 220 | case strings.Contains(pqErr.Error(), "email"): 221 | return ErrInvalidEmail.Wrap(errors.ErrValidation.Wrap(err)) 222 | case strings.Contains(pqErr.Error(), "nickname"): 223 | return ErrEmptyNickname.Wrap(errors.ErrValidation.Wrap(err)) 224 | case strings.Contains(pqErr.Error(), "password"): 225 | return ErrEmptyPassword.Wrap(errors.ErrValidation.Wrap(err)) 226 | case strings.Contains(pqErr.Error(), "country"): 227 | return ErrEmptyCountry.Wrap(errors.ErrValidation.Wrap(err)) 228 | default: 229 | return errors.ErrValidation.Wrap(err) 230 | } 231 | case "unique_violation": 232 | if strings.Contains(pqErr.Error(), "email_unique") { 233 | return ErrEmailAlreadyUsed.Wrap(errors.ErrValidation.Wrap(err)) 234 | } else if strings.Contains(pqErr.Error(), "nickname_unique") { 235 | return ErrNicknameAlreadyUsed.Wrap(errors.ErrValidation.Wrap(err)) 236 | } 237 | return errors.ErrValidation.Wrap(err) 238 | case "invalid_text_representation": 239 | if strings.Contains(pqErr.Error(), "uuid") { 240 | return ErrInvalidID.Wrap(errors.ErrValidation.Wrap(err)) 241 | } 242 | } 243 | } 244 | 245 | return errors.ErrUnknown.Wrap(err) 246 | } 247 | -------------------------------------------------------------------------------- /internal/users/store/store_exports_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "time" 4 | 5 | func ExportSetTimeNow(t time.Time) { 6 | timeNow = func() *time.Time { 7 | return &t 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /internal/users/store/store_integration_test.go: -------------------------------------------------------------------------------- 1 | package store_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/AlekSi/pointer" 12 | "github.com/ory/dockertest/v3" 13 | "github.com/speakeasy-api/rest-template-go/internal/core/drivers/psql" 14 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 15 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 16 | "github.com/speakeasy-api/rest-template-go/internal/users/store" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | const ( 22 | dsn = "postgresql://guest:guest@localhost:%s/speakeasy?sslmode=disable" 23 | ) 24 | 25 | var ( 26 | db *psql.Driver 27 | initialInsertedUserID string 28 | ) 29 | 30 | var initialUser *model.User = &model.User{ 31 | FirstName: pointer.ToString("testFirst"), 32 | LastName: pointer.ToString("testLast"), 33 | Nickname: pointer.ToString("test1"), 34 | Password: pointer.ToString("test"), 35 | Email: pointer.ToString("test1@test.com"), 36 | Country: pointer.ToString("UK"), 37 | } 38 | 39 | func TestMain(m *testing.M) { 40 | ctx := context.Background() 41 | 42 | // uses a sensible default on windows (tcp/http) and linux/osx (socket) 43 | pool, err := dockertest.NewPool("") 44 | if err != nil { 45 | log.Fatalf("Could not connect to docker: %s", err) 46 | } 47 | 48 | // pulls an image, creates a container based on it and runs it 49 | resource, err := pool.Run("postgres", "alpine", []string{"POSTGRES_USER=guest", "POSTGRES_PASSWORD=guest", "POSTGRES_DB=speakeasy"}) 50 | if err != nil { 51 | log.Fatalf("could not start resource: %v", err) 52 | } 53 | 54 | purge := func() { 55 | if err := pool.Purge(resource); err != nil { 56 | log.Fatalf("could not purge resource: %v", err) 57 | } 58 | } 59 | 60 | // exponential backoff-retry, because the application in the container might not be ready to accept connections yet 61 | if err := pool.Retry(func() error { 62 | db = psql.New(psql.Config{ 63 | DSN: fmt.Sprintf(dsn, resource.GetPort("5432/tcp")), 64 | }) 65 | if err := db.Connect(ctx); err != nil { 66 | log.Printf("could not connect to database: %v", err) 67 | return err 68 | } 69 | 70 | if err := db.GetDB().Ping(); err != nil { 71 | log.Printf("could not ping to database: %v", err) 72 | return err 73 | } 74 | 75 | return nil 76 | }); err != nil { 77 | purge() 78 | log.Fatalf("could not connect to database: %v", err) 79 | } 80 | 81 | if err := db.MigratePostgres(ctx, "file://../../../migrations"); err != nil { 82 | purge() 83 | log.Fatalf("could not migrate database: %v", err) 84 | } 85 | 86 | initialInsertedUserID, err = insertUser(ctx, initialUser) 87 | if err != nil { 88 | purge() 89 | log.Fatalf("could not insert user: %v", err) 90 | } 91 | 92 | code := m.Run() 93 | 94 | if err := db.Close(ctx); err != nil { 95 | purge() 96 | log.Fatalf("could not close db resource: %v", err) 97 | } 98 | 99 | purge() 100 | 101 | os.Exit(code) 102 | } 103 | 104 | func TestStore_InsertUser_Success(t *testing.T) { 105 | type args struct { 106 | user *model.User 107 | } 108 | tests := []struct { 109 | name string 110 | args args 111 | wantUser model.User 112 | }{ 113 | { 114 | name: "success", 115 | args: args{ 116 | user: &model.User{ 117 | FirstName: pointer.ToString("testFirst"), 118 | LastName: pointer.ToString("testLast"), 119 | Nickname: pointer.ToString("insertTest"), 120 | Password: pointer.ToString("test"), 121 | Email: pointer.ToString("inserttest@test.com"), 122 | Country: pointer.ToString("UK"), 123 | }, 124 | }, 125 | wantUser: model.User{ 126 | FirstName: pointer.ToString("testFirst"), 127 | LastName: pointer.ToString("testLast"), 128 | Nickname: pointer.ToString("insertTest"), 129 | Password: pointer.ToString("test"), 130 | Email: pointer.ToString("inserttest@test.com"), 131 | Country: pointer.ToString("UK"), 132 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 133 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 134 | }, 135 | }, 136 | } 137 | for _, tt := range tests { 138 | t.Run(tt.name, func(t *testing.T) { 139 | s := store.New(db.GetDB()) 140 | 141 | ctx := context.Background() 142 | 143 | store.ExportSetTimeNow(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) 144 | 145 | createdUser, err := s.InsertUser(ctx, tt.args.user) 146 | assert.NoError(t, err) 147 | require.NotNil(t, createdUser) 148 | 149 | tt.wantUser.ID = createdUser.ID 150 | 151 | assert.EqualValues(t, tt.wantUser, *createdUser) 152 | }) 153 | } 154 | } 155 | 156 | func TestStore_InsertUser_Error(t *testing.T) { 157 | type args struct { 158 | user *model.User 159 | } 160 | tests := []struct { 161 | name string 162 | args args 163 | wantErr1 error 164 | wantErr2 error 165 | }{ 166 | { 167 | name: "failed with invalid email", 168 | args: args{ 169 | user: &model.User{ 170 | FirstName: pointer.ToString("testFirst"), 171 | LastName: pointer.ToString("testLast"), 172 | Nickname: pointer.ToString("test2"), 173 | Password: pointer.ToString("test"), 174 | Email: pointer.ToString("test2@@test.com"), 175 | Country: pointer.ToString("UK"), 176 | }, 177 | }, 178 | wantErr1: errors.ErrValidation, 179 | wantErr2: store.ErrInvalidEmail, 180 | }, 181 | { 182 | name: "failed with not-unique email", 183 | args: args{ 184 | user: &model.User{ 185 | FirstName: pointer.ToString("testFirst"), 186 | LastName: pointer.ToString("testLast"), 187 | Nickname: pointer.ToString("test2"), 188 | Password: pointer.ToString("test"), 189 | Email: pointer.ToString("test1@test.com"), 190 | Country: pointer.ToString("UK"), 191 | }, 192 | }, 193 | wantErr1: errors.ErrValidation, 194 | wantErr2: store.ErrEmailAlreadyUsed, 195 | }, 196 | { 197 | name: "failed with not-unique nickname", 198 | args: args{ 199 | user: &model.User{ 200 | FirstName: pointer.ToString("testFirst"), 201 | LastName: pointer.ToString("testLast"), 202 | Nickname: pointer.ToString("test1"), 203 | Password: pointer.ToString("test"), 204 | Email: pointer.ToString("test2@test.com"), 205 | Country: pointer.ToString("UK"), 206 | }, 207 | }, 208 | wantErr1: errors.ErrValidation, 209 | wantErr2: store.ErrNicknameAlreadyUsed, 210 | }, 211 | { 212 | name: "failed with empty email", 213 | args: args{ 214 | user: &model.User{ 215 | FirstName: pointer.ToString("testFirst"), 216 | LastName: pointer.ToString("testLast"), 217 | Nickname: pointer.ToString("test2"), 218 | Password: pointer.ToString("test"), 219 | Email: pointer.ToString(""), 220 | Country: pointer.ToString("UK"), 221 | }, 222 | }, 223 | wantErr1: errors.ErrValidation, 224 | wantErr2: store.ErrInvalidEmail, 225 | }, 226 | { 227 | name: "failed with null email", 228 | args: args{ 229 | user: &model.User{ 230 | FirstName: pointer.ToString("testFirst"), 231 | LastName: pointer.ToString("testLast"), 232 | Nickname: pointer.ToString("test2"), 233 | Password: pointer.ToString("test"), 234 | Email: nil, 235 | Country: pointer.ToString("UK"), 236 | }, 237 | }, 238 | wantErr1: errors.ErrValidation, 239 | wantErr2: store.ErrInvalidEmail, 240 | }, 241 | { 242 | name: "failed with empty nickname", 243 | args: args{ 244 | user: &model.User{ 245 | FirstName: pointer.ToString("testFirst"), 246 | LastName: pointer.ToString("testLast"), 247 | Nickname: pointer.ToString(""), 248 | Password: pointer.ToString("test"), 249 | Email: pointer.ToString("test2@test.com"), 250 | Country: pointer.ToString("UK"), 251 | }, 252 | }, 253 | wantErr1: errors.ErrValidation, 254 | wantErr2: store.ErrEmptyNickname, 255 | }, 256 | { 257 | name: "failed with null nickname", 258 | args: args{ 259 | user: &model.User{ 260 | FirstName: pointer.ToString("testFirst"), 261 | LastName: pointer.ToString("testLast"), 262 | Nickname: nil, 263 | Password: pointer.ToString("test"), 264 | Email: pointer.ToString("test2@test.com"), 265 | Country: pointer.ToString("UK"), 266 | }, 267 | }, 268 | wantErr1: errors.ErrValidation, 269 | wantErr2: store.ErrEmptyNickname, 270 | }, 271 | { 272 | name: "failed with empty password", 273 | args: args{ 274 | user: &model.User{ 275 | FirstName: pointer.ToString("testFirst"), 276 | LastName: pointer.ToString("testLast"), 277 | Nickname: pointer.ToString("test2"), 278 | Password: pointer.ToString(""), 279 | Email: pointer.ToString("test2@test.com"), 280 | Country: pointer.ToString("UK"), 281 | }, 282 | }, 283 | wantErr1: errors.ErrValidation, 284 | wantErr2: store.ErrEmptyPassword, 285 | }, 286 | { 287 | name: "failed with null password", 288 | args: args{ 289 | user: &model.User{ 290 | FirstName: pointer.ToString("testFirst"), 291 | LastName: pointer.ToString("testLast"), 292 | Nickname: pointer.ToString("test2"), 293 | Password: nil, 294 | Email: pointer.ToString("test2@test.com"), 295 | Country: pointer.ToString("UK"), 296 | }, 297 | }, 298 | wantErr1: errors.ErrValidation, 299 | wantErr2: store.ErrEmptyPassword, 300 | }, 301 | { 302 | name: "failed with empty country", 303 | args: args{ 304 | user: &model.User{ 305 | FirstName: pointer.ToString("testFirst"), 306 | LastName: pointer.ToString("testLast"), 307 | Nickname: pointer.ToString("test2"), 308 | Password: pointer.ToString("test"), 309 | Email: pointer.ToString("test2@test.com"), 310 | Country: pointer.ToString(""), 311 | }, 312 | }, 313 | wantErr1: errors.ErrValidation, 314 | wantErr2: store.ErrEmptyCountry, 315 | }, 316 | { 317 | name: "failed with null country", 318 | args: args{ 319 | user: &model.User{ 320 | FirstName: pointer.ToString("testFirst"), 321 | LastName: pointer.ToString("testLast"), 322 | Nickname: pointer.ToString("test2"), 323 | Password: pointer.ToString("test"), 324 | Email: pointer.ToString("test2@test.com"), 325 | Country: nil, 326 | }, 327 | }, 328 | wantErr1: errors.ErrValidation, 329 | wantErr2: store.ErrEmptyCountry, 330 | }, 331 | } 332 | for _, tt := range tests { 333 | t.Run(tt.name, func(t *testing.T) { 334 | s := store.New(db.GetDB()) 335 | 336 | ctx := context.Background() 337 | 338 | createdUser, err := s.InsertUser(ctx, tt.args.user) 339 | assert.ErrorIs(t, err, tt.wantErr1) 340 | if tt.wantErr2 != nil { 341 | assert.ErrorIs(t, err, tt.wantErr2) 342 | } 343 | assert.Nil(t, createdUser) 344 | }) 345 | } 346 | } 347 | 348 | func TestStore_GetUser_Success(t *testing.T) { 349 | type args struct { 350 | id string 351 | } 352 | tests := []struct { 353 | name string 354 | args args 355 | wantUser model.User 356 | }{ 357 | { 358 | name: "success", 359 | args: args{ 360 | id: initialInsertedUserID, 361 | }, 362 | wantUser: model.User{ 363 | ID: pointer.ToString(initialInsertedUserID), 364 | FirstName: pointer.ToString("testFirst"), 365 | LastName: pointer.ToString("testLast"), 366 | Nickname: pointer.ToString("test1"), 367 | Password: pointer.ToString("test"), 368 | Email: pointer.ToString("test1@test.com"), 369 | Country: pointer.ToString("UK"), 370 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 371 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 372 | }, 373 | }, 374 | } 375 | for _, tt := range tests { 376 | t.Run(tt.name, func(t *testing.T) { 377 | s := store.New(db.GetDB()) 378 | 379 | ctx := context.Background() 380 | 381 | user, err := s.GetUser(ctx, tt.args.id) 382 | assert.NoError(t, err) 383 | require.NotNil(t, user) 384 | assert.Equal(t, tt.wantUser, *user) 385 | }) 386 | } 387 | } 388 | 389 | func TestStore_GetUser_Error(t *testing.T) { 390 | type args struct { 391 | id string 392 | } 393 | tests := []struct { 394 | name string 395 | args args 396 | wantErr1 error 397 | wantErr2 error 398 | }{ 399 | { 400 | name: "fail to find user", 401 | args: args{ 402 | id: "9cdd8ae2-15ab-40df-9c46-50f391e16f60", // If we ever get a conflict this test will fail 403 | }, 404 | wantErr1: errors.ErrNotFound, 405 | wantErr2: errors.ErrNotFound, 406 | }, 407 | { 408 | name: "failed to get user with invalid id", 409 | args: args{ 410 | id: "some-invalid-id", 411 | }, 412 | wantErr1: errors.ErrValidation, 413 | wantErr2: store.ErrInvalidID, 414 | }, 415 | } 416 | for _, tt := range tests { 417 | t.Run(tt.name, func(t *testing.T) { 418 | s := store.New(db.GetDB()) 419 | 420 | ctx := context.Background() 421 | 422 | user, err := s.GetUser(ctx, tt.args.id) 423 | assert.ErrorIs(t, err, tt.wantErr1) 424 | assert.ErrorIs(t, err, tt.wantErr2) 425 | assert.Nil(t, user) 426 | }) 427 | } 428 | } 429 | 430 | func TestStore_GetUserByEmail_Success(t *testing.T) { 431 | type args struct { 432 | email string 433 | } 434 | tests := []struct { 435 | name string 436 | args args 437 | wantUser model.User 438 | }{ 439 | { 440 | name: "success", 441 | args: args{ 442 | email: "test1@test.com", 443 | }, 444 | wantUser: model.User{ 445 | ID: pointer.ToString(initialInsertedUserID), 446 | FirstName: pointer.ToString("testFirst"), 447 | LastName: pointer.ToString("testLast"), 448 | Nickname: pointer.ToString("test1"), 449 | Password: pointer.ToString("test"), 450 | Email: pointer.ToString("test1@test.com"), 451 | Country: pointer.ToString("UK"), 452 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 453 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 454 | }, 455 | }, 456 | } 457 | for _, tt := range tests { 458 | t.Run(tt.name, func(t *testing.T) { 459 | s := store.New(db.GetDB()) 460 | 461 | ctx := context.Background() 462 | 463 | user, err := s.GetUserByEmail(ctx, tt.args.email) 464 | assert.NoError(t, err) 465 | require.NotNil(t, user) 466 | assert.Equal(t, tt.wantUser, *user) 467 | }) 468 | } 469 | } 470 | 471 | func TestStore_GetUserByEmail_Error(t *testing.T) { 472 | type args struct { 473 | email string 474 | } 475 | tests := []struct { 476 | name string 477 | args args 478 | wantErr error 479 | }{ 480 | { 481 | name: "fail to find user by email", 482 | args: args{ 483 | email: "not@found.com", 484 | }, 485 | wantErr: errors.ErrNotFound, 486 | }, 487 | } 488 | for _, tt := range tests { 489 | t.Run(tt.name, func(t *testing.T) { 490 | s := store.New(db.GetDB()) 491 | 492 | ctx := context.Background() 493 | 494 | user, err := s.GetUserByEmail(ctx, tt.args.email) 495 | assert.ErrorIs(t, err, tt.wantErr) 496 | assert.Nil(t, user) 497 | }) 498 | } 499 | } 500 | 501 | func TestStore_UpdateUser_Success(t *testing.T) { 502 | updateTestUserID, err := insertUser(context.Background(), &model.User{ 503 | FirstName: pointer.ToString("testFirst"), 504 | LastName: pointer.ToString("testLast"), 505 | Nickname: pointer.ToString("updateTest"), 506 | Password: pointer.ToString("test"), 507 | Email: pointer.ToString("updatetest@test.com"), 508 | Country: pointer.ToString("UK"), 509 | }) 510 | require.NoError(t, err) 511 | 512 | type fields struct { 513 | updateDay int 514 | } 515 | type args struct { 516 | user *model.User 517 | } 518 | tests := []struct { 519 | name string 520 | fields fields 521 | args args 522 | wantUser model.User 523 | }{ 524 | { 525 | name: "success updating first name", 526 | fields: fields{ 527 | updateDay: 1, 528 | }, 529 | args: args{ 530 | user: &model.User{ 531 | ID: &updateTestUserID, 532 | FirstName: pointer.ToString("firstNameUpdate"), 533 | }, 534 | }, 535 | wantUser: model.User{ 536 | ID: &updateTestUserID, 537 | FirstName: pointer.ToString("firstNameUpdate"), 538 | LastName: pointer.ToString("testLast"), 539 | Nickname: pointer.ToString("updateTest"), 540 | Password: pointer.ToString("test"), 541 | Email: pointer.ToString("updatetest@test.com"), 542 | Country: pointer.ToString("UK"), 543 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 544 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)), 545 | }, 546 | }, 547 | { 548 | name: "success updating last name", 549 | fields: fields{ 550 | updateDay: 2, 551 | }, 552 | args: args{ 553 | user: &model.User{ 554 | ID: &updateTestUserID, 555 | LastName: pointer.ToString("lastNameUpdate"), 556 | }, 557 | }, 558 | wantUser: model.User{ 559 | ID: &updateTestUserID, 560 | FirstName: pointer.ToString("firstNameUpdate"), 561 | LastName: pointer.ToString("lastNameUpdate"), 562 | Nickname: pointer.ToString("updateTest"), 563 | Password: pointer.ToString("test"), 564 | Email: pointer.ToString("updatetest@test.com"), 565 | Country: pointer.ToString("UK"), 566 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 567 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 2, 0, 0, 0, 0, time.UTC)), 568 | }, 569 | }, 570 | { 571 | name: "success updating nickname", 572 | fields: fields{ 573 | updateDay: 3, 574 | }, 575 | args: args{ 576 | user: &model.User{ 577 | ID: &updateTestUserID, 578 | Nickname: pointer.ToString("nicknameUpdate"), 579 | }, 580 | }, 581 | wantUser: model.User{ 582 | ID: &updateTestUserID, 583 | FirstName: pointer.ToString("firstNameUpdate"), 584 | LastName: pointer.ToString("lastNameUpdate"), 585 | Nickname: pointer.ToString("nicknameUpdate"), 586 | Password: pointer.ToString("test"), 587 | Email: pointer.ToString("updatetest@test.com"), 588 | Country: pointer.ToString("UK"), 589 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 590 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 3, 0, 0, 0, 0, time.UTC)), 591 | }, 592 | }, 593 | { 594 | name: "success updating password", 595 | fields: fields{ 596 | updateDay: 4, 597 | }, 598 | args: args{ 599 | user: &model.User{ 600 | ID: &updateTestUserID, 601 | Password: pointer.ToString("passwordUpdate"), 602 | }, 603 | }, 604 | wantUser: model.User{ 605 | ID: &updateTestUserID, 606 | FirstName: pointer.ToString("firstNameUpdate"), 607 | LastName: pointer.ToString("lastNameUpdate"), 608 | Nickname: pointer.ToString("nicknameUpdate"), 609 | Password: pointer.ToString("passwordUpdate"), 610 | Email: pointer.ToString("updatetest@test.com"), 611 | Country: pointer.ToString("UK"), 612 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 613 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 4, 0, 0, 0, 0, time.UTC)), 614 | }, 615 | }, 616 | { 617 | name: "success updating email", 618 | fields: fields{ 619 | updateDay: 5, 620 | }, 621 | args: args{ 622 | user: &model.User{ 623 | ID: &updateTestUserID, 624 | Email: pointer.ToString("emailupdate@test.com"), 625 | }, 626 | }, 627 | wantUser: model.User{ 628 | ID: &updateTestUserID, 629 | FirstName: pointer.ToString("firstNameUpdate"), 630 | LastName: pointer.ToString("lastNameUpdate"), 631 | Nickname: pointer.ToString("nicknameUpdate"), 632 | Password: pointer.ToString("passwordUpdate"), 633 | Email: pointer.ToString("emailupdate@test.com"), 634 | Country: pointer.ToString("UK"), 635 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 636 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 5, 0, 0, 0, 0, time.UTC)), 637 | }, 638 | }, 639 | { 640 | name: "success updating country", 641 | fields: fields{ 642 | updateDay: 6, 643 | }, 644 | args: args{ 645 | user: &model.User{ 646 | ID: &updateTestUserID, 647 | Country: pointer.ToString("IT"), 648 | }, 649 | }, 650 | wantUser: model.User{ 651 | ID: &updateTestUserID, 652 | FirstName: pointer.ToString("firstNameUpdate"), 653 | LastName: pointer.ToString("lastNameUpdate"), 654 | Nickname: pointer.ToString("nicknameUpdate"), 655 | Password: pointer.ToString("passwordUpdate"), 656 | Email: pointer.ToString("emailupdate@test.com"), 657 | Country: pointer.ToString("IT"), 658 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 659 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 6, 0, 0, 0, 0, time.UTC)), 660 | }, 661 | }, 662 | { 663 | name: "success updating everything", 664 | fields: fields{ 665 | updateDay: 6, 666 | }, 667 | args: args{ 668 | user: &model.User{ 669 | ID: &updateTestUserID, 670 | FirstName: pointer.ToString("firstNameUpdate2"), 671 | LastName: pointer.ToString("lastNameUpdate2"), 672 | Nickname: pointer.ToString("nicknameUpdate2"), 673 | Password: pointer.ToString("passwordUpdate2"), 674 | Email: pointer.ToString("emailupdate2@test.com"), 675 | Country: pointer.ToString("US"), 676 | }, 677 | }, 678 | wantUser: model.User{ 679 | ID: &updateTestUserID, 680 | FirstName: pointer.ToString("firstNameUpdate2"), 681 | LastName: pointer.ToString("lastNameUpdate2"), 682 | Nickname: pointer.ToString("nicknameUpdate2"), 683 | Password: pointer.ToString("passwordUpdate2"), 684 | Email: pointer.ToString("emailupdate2@test.com"), 685 | Country: pointer.ToString("US"), 686 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 687 | UpdatedAt: pointer.ToTime(time.Date(2021, time.January, 6, 0, 0, 0, 0, time.UTC)), 688 | }, 689 | }, 690 | } 691 | for _, tt := range tests { 692 | t.Run(tt.name, func(t *testing.T) { 693 | s := store.New(db.GetDB()) 694 | 695 | ctx := context.Background() 696 | 697 | store.ExportSetTimeNow(time.Date(2021, time.January, tt.fields.updateDay, 0, 0, 0, 0, time.UTC)) 698 | 699 | updatedUser, err := s.UpdateUser(ctx, tt.args.user) 700 | assert.NoError(t, err) 701 | require.NotNil(t, updatedUser) 702 | assert.Equal(t, tt.wantUser, *updatedUser) 703 | }) 704 | } 705 | } 706 | 707 | func TestStore_UpdateUser_Error(t *testing.T) { 708 | type args struct { 709 | user *model.User 710 | } 711 | tests := []struct { 712 | name string 713 | args args 714 | wantErr1 error 715 | wantErr2 error 716 | }{ 717 | { 718 | name: "failed with missing id", 719 | args: args{ 720 | user: &model.User{ 721 | ID: nil, 722 | Nickname: pointer.ToString("testNickname"), 723 | }, 724 | }, 725 | wantErr1: errors.ErrValidation, 726 | wantErr2: store.ErrInvalidID, 727 | }, 728 | { 729 | name: "failed with empty id", 730 | args: args{ 731 | user: &model.User{ 732 | ID: pointer.ToString(""), 733 | Nickname: pointer.ToString("testNickname"), 734 | }, 735 | }, 736 | wantErr1: errors.ErrValidation, 737 | wantErr2: store.ErrInvalidID, 738 | }, 739 | { 740 | name: "failed with invalid id", 741 | args: args{ 742 | user: &model.User{ 743 | ID: pointer.ToString("invalid-id"), 744 | Email: pointer.ToString("updatetest@test.com"), 745 | }, 746 | }, 747 | wantErr1: errors.ErrValidation, 748 | wantErr2: store.ErrInvalidID, 749 | }, 750 | { 751 | name: "failed with empty nickname", 752 | args: args{ 753 | user: &model.User{ 754 | ID: &initialInsertedUserID, 755 | Nickname: pointer.ToString(""), 756 | }, 757 | }, 758 | wantErr1: errors.ErrValidation, 759 | wantErr2: store.ErrEmptyNickname, 760 | }, 761 | { 762 | name: "failed with empty password", 763 | args: args{ 764 | user: &model.User{ 765 | ID: &initialInsertedUserID, 766 | Password: pointer.ToString(""), 767 | }, 768 | }, 769 | wantErr1: errors.ErrValidation, 770 | wantErr2: store.ErrEmptyPassword, 771 | }, 772 | { 773 | name: "failed with empty email", 774 | args: args{ 775 | user: &model.User{ 776 | ID: &initialInsertedUserID, 777 | Email: pointer.ToString(""), 778 | }, 779 | }, 780 | wantErr1: errors.ErrValidation, 781 | wantErr2: store.ErrInvalidEmail, 782 | }, 783 | { 784 | name: "failed with invalid email", 785 | args: args{ 786 | user: &model.User{ 787 | ID: &initialInsertedUserID, 788 | Email: pointer.ToString("test@@test.com"), 789 | }, 790 | }, 791 | wantErr1: errors.ErrValidation, 792 | wantErr2: store.ErrInvalidEmail, 793 | }, 794 | { 795 | name: "failed with empty country", 796 | args: args{ 797 | user: &model.User{ 798 | ID: &initialInsertedUserID, 799 | Country: pointer.ToString(""), 800 | }, 801 | }, 802 | wantErr1: errors.ErrValidation, 803 | wantErr2: store.ErrEmptyCountry, 804 | }, 805 | { 806 | name: "failed updating non-existent user", 807 | args: args{ 808 | user: &model.User{ 809 | ID: pointer.ToString("15b76e47-40df-43e6-9d1e-02b72c473914"), // Will fail if we get any UUID conflicts on pre inserted users 810 | Email: pointer.ToString("updatetest@test.com"), 811 | }, 812 | }, 813 | wantErr1: errors.ErrNotFound, 814 | wantErr2: store.ErrUserNotUpdated, 815 | }, 816 | } 817 | for _, tt := range tests { 818 | t.Run(tt.name, func(t *testing.T) { 819 | s := store.New(db.GetDB()) 820 | 821 | ctx := context.Background() 822 | 823 | store.ExportSetTimeNow(time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)) 824 | 825 | updatedUser, err := s.UpdateUser(ctx, tt.args.user) 826 | assert.ErrorIs(t, err, tt.wantErr1) 827 | assert.ErrorIs(t, err, tt.wantErr2) 828 | assert.Nil(t, updatedUser) 829 | }) 830 | } 831 | } 832 | 833 | func TestStore_DeleteUser_Success(t *testing.T) { 834 | deleteTestUserID, err := insertUser(context.Background(), &model.User{ 835 | FirstName: pointer.ToString("testFirst"), 836 | LastName: pointer.ToString("testLast"), 837 | Nickname: pointer.ToString("deleteTest"), 838 | Password: pointer.ToString("test"), 839 | Email: pointer.ToString("deletetest@test.com"), 840 | Country: pointer.ToString("UK"), 841 | }) 842 | require.NoError(t, err) 843 | 844 | type args struct { 845 | id string 846 | } 847 | tests := []struct { 848 | name string 849 | args args 850 | }{ 851 | { 852 | name: "success", 853 | args: args{ 854 | id: deleteTestUserID, 855 | }, 856 | }, 857 | } 858 | for _, tt := range tests { 859 | t.Run(tt.name, func(t *testing.T) { 860 | s := store.New(db.GetDB()) 861 | 862 | ctx := context.Background() 863 | 864 | err := s.DeleteUser(ctx, tt.args.id) 865 | assert.NoError(t, err) 866 | }) 867 | } 868 | } 869 | 870 | func TestStore_DeleteUser_Error(t *testing.T) { 871 | type args struct { 872 | id string 873 | } 874 | tests := []struct { 875 | name string 876 | args args 877 | wantErr1 error 878 | wantErr2 error 879 | }{ 880 | { 881 | name: "failed to delete non-existent user", 882 | args: args{ 883 | id: "4f54b006-e7d9-47bf-ad38-d56c75a032cf", 884 | }, 885 | wantErr1: errors.ErrNotFound, 886 | wantErr2: store.ErrUserNotDeleted, 887 | }, 888 | { 889 | name: "failed to delete user with invalid id", 890 | args: args{ 891 | id: "some-invalid-id", 892 | }, 893 | wantErr1: errors.ErrValidation, 894 | wantErr2: store.ErrInvalidID, 895 | }, 896 | } 897 | for _, tt := range tests { 898 | t.Run(tt.name, func(t *testing.T) { 899 | s := store.New(db.GetDB()) 900 | 901 | ctx := context.Background() 902 | 903 | err := s.DeleteUser(ctx, tt.args.id) 904 | assert.ErrorIs(t, err, tt.wantErr1) 905 | assert.ErrorIs(t, err, tt.wantErr2) 906 | }) 907 | } 908 | } 909 | 910 | func insertUser(ctx context.Context, u *model.User) (string, error) { 911 | s := store.New(db.GetDB()) 912 | 913 | store.ExportSetTimeNow(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) 914 | 915 | createdUser, err := s.InsertUser(ctx, u) 916 | if err != nil { 917 | return "", err 918 | } 919 | 920 | return *createdUser.ID, nil 921 | } 922 | -------------------------------------------------------------------------------- /internal/users/users.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -destination=./mocks/users_mock.go -package mocks github.com/speakeasy-api/rest-template-go/internal/users Store,Events 2 | 3 | package users 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 9 | "github.com/speakeasy-api/rest-template-go/internal/core/logging" 10 | "github.com/speakeasy-api/rest-template-go/internal/events" 11 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const ( 16 | // ErrInvalidFilterValue is returned when a filter value is empty. 17 | ErrInvalidFilterValue = errors.Error("invalid_filter_value: invalid filter value") 18 | // ErrInvalidFilterMatchType is returned when a filter match type is not found in the supported enum list. 19 | ErrInvalidFilterMatchType = errors.Error("invalid_filter_match_type: invalid filter match type") 20 | // ErrInvalidFilterField is returned when a filter field is not found in the supported enum list. 21 | ErrInvalidFilterField = errors.Error("invalid_filter_field: invalid filter field") 22 | ) 23 | 24 | // Store represents a type for storing a user in a database. 25 | type Store interface { 26 | InsertUser(ctx context.Context, user *model.User) (*model.User, error) 27 | UpdateUser(ctx context.Context, user *model.User) (*model.User, error) 28 | GetUser(ctx context.Context, id string) (*model.User, error) 29 | GetUserByEmail(ctx context.Context, email string) (*model.User, error) 30 | FindUsers(ctx context.Context, filters []model.Filter, offset, limit int64) ([]*model.User, error) 31 | DeleteUser(ctx context.Context, id string) error 32 | } 33 | 34 | // Events represents a type for producing events on user CRUD operations. 35 | type Events interface { 36 | Produce(ctx context.Context, topic events.Topic, payload interface{}) 37 | } 38 | 39 | // Users provides functionality for CRUD operations on a user. 40 | type Users struct { 41 | store Store 42 | events Events 43 | } 44 | 45 | // New will instantiate a new instance of Users. 46 | func New(s Store, e Events) *Users { 47 | return &Users{ 48 | store: s, 49 | events: e, 50 | } 51 | } 52 | 53 | // CreateUser will try to create a user in our database with the provided data if it represents a unique new user. 54 | func (u *Users) CreateUser(ctx context.Context, user *model.User) (*model.User, error) { 55 | // Not much validation needed before storing in the database as the database itself is handling most of that (postgres) 56 | // if we were to use something else you would probably want to add validation of inputs here 57 | createdUser, err := u.store.InsertUser(ctx, user) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | // Assuming our events producer has some guarantees on retries and recovering from failures 63 | // we shouldn't need to worry about failures to send the event here and assume it will be sent eventually. 64 | // In an ideal world our producer would in the case of failing to send an event have some sort 65 | // of recovery mechanism to ensure that we don't lose any events. Such as picking up failed events on 66 | // a later run, and retrying them. 67 | // We shouldn't need to fail the whole process if we can't produce an event right now. 68 | u.events.Produce(ctx, events.TopicUsers, events.UserEvent{ 69 | EventType: events.EventTypeUserCreated, 70 | ID: *createdUser.ID, 71 | User: createdUser, 72 | }) 73 | 74 | return createdUser, nil 75 | } 76 | 77 | // GetUser will try to get an existing user in our database with the provided id. 78 | func (u *Users) GetUser(ctx context.Context, id string) (*model.User, error) { 79 | user, err := u.store.GetUser(ctx, id) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return user, nil 85 | } 86 | 87 | // FindUsers will retrieve a list of users based on matching all of the the provided filters and using pagination if limit is gt 0. 88 | func (u *Users) FindUsers(ctx context.Context, filters []model.Filter, offset, limit int64) ([]*model.User, error) { 89 | // Validate filters before searching with them 90 | // TODO may want to return details of error instead of just logging 91 | for i, f := range filters { 92 | if f.Value == "" { 93 | err := ErrInvalidFilterValue.Wrap(errors.ErrValidation) 94 | logging.From(ctx).Error("empty filter value provided", zap.Error(err), zap.Int("index", i)) 95 | return nil, err 96 | } 97 | 98 | switch f.MatchType { 99 | case model.MatchTypeEqual, model.MatchTypeLike: 100 | // noop 101 | default: 102 | err := ErrInvalidFilterMatchType.Wrap(errors.ErrValidation) 103 | logging.From(ctx).Error("match type not supported", zap.Error(err), zap.String("match_type", string(f.MatchType)), zap.Int("index", i)) 104 | return nil, err 105 | } 106 | 107 | switch f.Field { 108 | case model.FieldFirstName, model.FieldLastName, model.FieldNickname, model.FieldEmail, model.FieldCountry: 109 | // noop 110 | default: 111 | err := ErrInvalidFilterField.Wrap(errors.ErrValidation) 112 | logging.From(ctx).Error("filter field not supported", zap.Error(err), zap.String("field", string(f.Field)), zap.Int("index", i)) 113 | return nil, err 114 | } 115 | } 116 | 117 | users, err := u.store.FindUsers(ctx, filters, offset, limit) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | return users, nil 123 | } 124 | 125 | // UpdateUser will try to update an existing user in our database with the provided data. 126 | func (u *Users) UpdateUser(ctx context.Context, user *model.User) (*model.User, error) { 127 | updatedUser, err := u.store.UpdateUser(ctx, user) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | u.events.Produce(ctx, events.TopicUsers, events.UserEvent{ 133 | EventType: events.EventTypeUserUpdated, 134 | ID: *updatedUser.ID, 135 | User: updatedUser, 136 | }) 137 | 138 | return updatedUser, nil 139 | } 140 | 141 | // DeleteUser will try to delete an existing user in our database with the provided id. 142 | func (u *Users) DeleteUser(ctx context.Context, id string) error { 143 | err := u.store.DeleteUser(ctx, id) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | u.events.Produce(ctx, events.TopicUsers, events.UserEvent{ 149 | EventType: events.EventTypeUserDeleted, 150 | ID: id, 151 | }) 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /internal/users/users_test.go: -------------------------------------------------------------------------------- 1 | package users_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/AlekSi/pointer" 9 | "github.com/golang/mock/gomock" 10 | "github.com/speakeasy-api/rest-template-go/internal/core/errors" 11 | "github.com/speakeasy-api/rest-template-go/internal/events" 12 | "github.com/speakeasy-api/rest-template-go/internal/users" 13 | "github.com/speakeasy-api/rest-template-go/internal/users/mocks" 14 | "github.com/speakeasy-api/rest-template-go/internal/users/model" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func TestNew_Success(t *testing.T) { 20 | ctrl := gomock.NewController(t) 21 | defer ctrl.Finish() 22 | 23 | s := mocks.NewMockStore(ctrl) 24 | e := mocks.NewMockEvents(ctrl) 25 | 26 | u := users.New(s, e) 27 | assert.NotNil(t, u) 28 | } 29 | 30 | func TestUsers_CreateUser_Success(t *testing.T) { 31 | type args struct { 32 | user *model.User 33 | } 34 | tests := []struct { 35 | name string 36 | args args 37 | wantUser *model.User 38 | }{ 39 | { 40 | name: "success", 41 | args: args{ 42 | user: &model.User{ 43 | FirstName: pointer.ToString("testFirst"), 44 | LastName: pointer.ToString("testLast"), 45 | Nickname: pointer.ToString("test"), 46 | Password: pointer.ToString("test"), 47 | Email: pointer.ToString("test@test.com"), 48 | Country: pointer.ToString("UK"), 49 | }, 50 | }, 51 | wantUser: &model.User{ 52 | ID: pointer.ToString("some-test-id"), 53 | FirstName: pointer.ToString("testFirst"), 54 | LastName: pointer.ToString("testLast"), 55 | Nickname: pointer.ToString("test"), 56 | Password: pointer.ToString("test"), 57 | Email: pointer.ToString("test@test.com"), 58 | Country: pointer.ToString("UK"), 59 | CreatedAt: pointer.ToTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), 60 | UpdatedAt: pointer.ToTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)), 61 | }, 62 | }, 63 | } 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | ctrl := gomock.NewController(t) 67 | defer ctrl.Finish() 68 | 69 | s := mocks.NewMockStore(ctrl) 70 | e := mocks.NewMockEvents(ctrl) 71 | 72 | u := users.New(s, e) 73 | require.NotNil(t, u) 74 | 75 | ctx := context.Background() 76 | 77 | s.EXPECT().InsertUser(gomock.Any(), tt.args.user).Return(tt.wantUser, nil).Times(1) 78 | e.EXPECT().Produce(gomock.Any(), events.TopicUsers, events.UserEvent{ 79 | EventType: events.EventTypeUserCreated, 80 | ID: *tt.wantUser.ID, 81 | User: tt.wantUser, 82 | }).Times(1) 83 | 84 | user, err := u.CreateUser(ctx, tt.args.user) 85 | assert.NoError(t, err) 86 | assert.Equal(t, tt.wantUser, user) 87 | }) 88 | } 89 | } 90 | 91 | func TestUsers_CreateUser_Error(t *testing.T) { 92 | type args struct { 93 | user *model.User 94 | } 95 | tests := []struct { 96 | name string 97 | args args 98 | wantErr error 99 | }{ 100 | { 101 | name: "fails", 102 | args: args{ 103 | user: &model.User{ 104 | FirstName: pointer.ToString("testFirst"), 105 | LastName: pointer.ToString("testLast"), 106 | Nickname: pointer.ToString("test"), 107 | Password: pointer.ToString("test"), 108 | Email: pointer.ToString("test@test.com"), 109 | Country: pointer.ToString("UK"), 110 | }, 111 | }, 112 | wantErr: errors.New("test fail"), 113 | }, 114 | } 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | ctrl := gomock.NewController(t) 118 | defer ctrl.Finish() 119 | 120 | s := mocks.NewMockStore(ctrl) 121 | e := mocks.NewMockEvents(ctrl) 122 | 123 | u := users.New(s, e) 124 | require.NotNil(t, u) 125 | 126 | ctx := context.Background() 127 | 128 | s.EXPECT().InsertUser(gomock.Any(), tt.args.user).Return(nil, tt.wantErr).Times(1) 129 | 130 | user, err := u.CreateUser(ctx, tt.args.user) 131 | assert.ErrorIs(t, err, tt.wantErr) 132 | assert.Nil(t, user) 133 | }) 134 | } 135 | } 136 | 137 | func TestUsers_GetUser_Success(t *testing.T) { 138 | type args struct { 139 | id string 140 | } 141 | tests := []struct { 142 | name string 143 | args args 144 | wantUser *model.User 145 | }{ 146 | { 147 | name: "success", 148 | args: args{}, 149 | wantUser: &model.User{ 150 | ID: pointer.ToString("some-test-id"), 151 | FirstName: pointer.ToString("testFirst"), 152 | LastName: pointer.ToString("testLast"), 153 | Nickname: pointer.ToString("test"), 154 | Password: pointer.ToString("test"), 155 | Email: pointer.ToString("test@test.com"), 156 | Country: pointer.ToString("UK"), 157 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 158 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 159 | }, 160 | }, 161 | } 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | ctrl := gomock.NewController(t) 165 | defer ctrl.Finish() 166 | 167 | s := mocks.NewMockStore(ctrl) 168 | e := mocks.NewMockEvents(ctrl) 169 | 170 | u := users.New(s, e) 171 | require.NotNil(t, u) 172 | 173 | ctx := context.Background() 174 | 175 | s.EXPECT().GetUser(gomock.Any(), tt.args.id).Return(tt.wantUser, nil).Times(1) 176 | 177 | user, err := u.GetUser(ctx, tt.args.id) 178 | assert.NoError(t, err) 179 | assert.EqualValues(t, tt.wantUser, user) 180 | }) 181 | } 182 | } 183 | 184 | func TestUsers_GetUser_Error(t *testing.T) { 185 | type args struct { 186 | id string 187 | } 188 | tests := []struct { 189 | name string 190 | args args 191 | wantErr error 192 | }{ 193 | { 194 | name: "fails", 195 | args: args{ 196 | id: "some-test-id", 197 | }, 198 | wantErr: errors.New("test fail"), 199 | }, 200 | } 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | ctrl := gomock.NewController(t) 204 | defer ctrl.Finish() 205 | 206 | s := mocks.NewMockStore(ctrl) 207 | e := mocks.NewMockEvents(ctrl) 208 | 209 | u := users.New(s, e) 210 | require.NotNil(t, u) 211 | 212 | ctx := context.Background() 213 | 214 | s.EXPECT().GetUser(gomock.Any(), tt.args.id).Return(nil, tt.wantErr).Times(1) 215 | 216 | user, err := u.GetUser(ctx, tt.args.id) 217 | assert.ErrorIs(t, err, tt.wantErr) 218 | assert.Nil(t, user) 219 | }) 220 | } 221 | } 222 | 223 | func TestUsers_FindUsers_Success(t *testing.T) { 224 | type args struct { 225 | filters []model.Filter 226 | offset int64 227 | limit int64 228 | } 229 | tests := []struct { 230 | name string 231 | args args 232 | wantUsers []*model.User 233 | }{ 234 | { 235 | name: "success", 236 | args: args{ 237 | filters: []model.Filter{ 238 | { 239 | Field: model.FieldCountry, 240 | MatchType: model.MatchTypeEqual, 241 | Value: "UK", 242 | }, 243 | }, 244 | offset: 0, 245 | limit: 10, 246 | }, 247 | wantUsers: []*model.User{ 248 | { 249 | ID: pointer.ToString("some-test-id"), 250 | FirstName: pointer.ToString("testFirst"), 251 | LastName: pointer.ToString("testLast"), 252 | Nickname: pointer.ToString("test"), 253 | Password: pointer.ToString("test"), 254 | Email: pointer.ToString("test@test.com"), 255 | Country: pointer.ToString("UK"), 256 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 257 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 258 | }, 259 | }, 260 | }, 261 | } 262 | for _, tt := range tests { 263 | t.Run(tt.name, func(t *testing.T) { 264 | ctrl := gomock.NewController(t) 265 | defer ctrl.Finish() 266 | 267 | s := mocks.NewMockStore(ctrl) 268 | e := mocks.NewMockEvents(ctrl) 269 | 270 | u := users.New(s, e) 271 | require.NotNil(t, u) 272 | 273 | ctx := context.Background() 274 | 275 | s.EXPECT().FindUsers(gomock.Any(), tt.args.filters, tt.args.offset, tt.args.limit).Return(tt.wantUsers, nil).Times(1) 276 | 277 | users, err := u.FindUsers(ctx, tt.args.filters, tt.args.offset, tt.args.limit) 278 | assert.NoError(t, err) 279 | assert.EqualValues(t, tt.wantUsers, users) 280 | }) 281 | } 282 | } 283 | 284 | func TestUsers_FindUsers_Error(t *testing.T) { 285 | type fields struct { 286 | findUsersErr error 287 | } 288 | type args struct { 289 | filters []model.Filter 290 | offset int64 291 | limit int64 292 | } 293 | tests := []struct { 294 | name string 295 | fields fields 296 | args args 297 | wantErr1 error 298 | wantErr2 error 299 | }{ 300 | { 301 | name: "fails when search fails", 302 | fields: fields{ 303 | findUsersErr: errors.ErrUnknown, 304 | }, 305 | args: args{ 306 | filters: []model.Filter{ 307 | { 308 | Field: model.FieldCountry, 309 | MatchType: model.MatchTypeEqual, 310 | Value: "UK", 311 | }, 312 | }, 313 | offset: 0, 314 | limit: 10, 315 | }, 316 | wantErr1: errors.ErrUnknown, 317 | wantErr2: errors.ErrUnknown, 318 | }, 319 | { 320 | name: "fails with empty value", 321 | args: args{ 322 | filters: []model.Filter{ 323 | { 324 | Field: model.FieldCountry, 325 | MatchType: model.MatchTypeEqual, 326 | Value: "", 327 | }, 328 | }, 329 | offset: 0, 330 | limit: 10, 331 | }, 332 | wantErr1: errors.ErrValidation, 333 | wantErr2: users.ErrInvalidFilterValue, 334 | }, 335 | { 336 | name: "fails with invalid match type", 337 | args: args{ 338 | filters: []model.Filter{ 339 | { 340 | Field: model.FieldCountry, 341 | MatchType: "invalid", 342 | Value: "UK", 343 | }, 344 | }, 345 | offset: 0, 346 | limit: 10, 347 | }, 348 | wantErr1: errors.ErrValidation, 349 | wantErr2: users.ErrInvalidFilterMatchType, 350 | }, 351 | { 352 | name: "fails with invalid field", 353 | args: args{ 354 | filters: []model.Filter{ 355 | { 356 | Field: "invalid", 357 | MatchType: model.MatchTypeEqual, 358 | Value: "UK", 359 | }, 360 | }, 361 | offset: 0, 362 | limit: 10, 363 | }, 364 | wantErr1: errors.ErrValidation, 365 | wantErr2: users.ErrInvalidFilterField, 366 | }, 367 | } 368 | for _, tt := range tests { 369 | t.Run(tt.name, func(t *testing.T) { 370 | ctrl := gomock.NewController(t) 371 | defer ctrl.Finish() 372 | 373 | s := mocks.NewMockStore(ctrl) 374 | e := mocks.NewMockEvents(ctrl) 375 | 376 | u := users.New(s, e) 377 | require.NotNil(t, u) 378 | 379 | ctx := context.Background() 380 | 381 | if tt.fields.findUsersErr != nil { 382 | s.EXPECT().FindUsers(gomock.Any(), tt.args.filters, tt.args.offset, tt.args.limit).Return(nil, tt.fields.findUsersErr).Times(1) 383 | } 384 | 385 | user, err := u.FindUsers(ctx, tt.args.filters, tt.args.offset, tt.args.limit) 386 | assert.ErrorIs(t, err, tt.wantErr1) 387 | assert.ErrorIs(t, err, tt.wantErr2) 388 | assert.Nil(t, user) 389 | }) 390 | } 391 | } 392 | 393 | func TestUsers_UpdateUser_Success(t *testing.T) { 394 | type args struct { 395 | user *model.User 396 | } 397 | tests := []struct { 398 | name string 399 | args args 400 | wantUser *model.User 401 | }{ 402 | { 403 | name: "success", 404 | args: args{ 405 | user: &model.User{ 406 | ID: pointer.ToString("some-test-id"), 407 | FirstName: pointer.ToString("testFirst"), 408 | LastName: pointer.ToString("testLast"), 409 | Nickname: pointer.ToString("test"), 410 | Password: pointer.ToString("test"), 411 | Email: pointer.ToString("test@test.com"), 412 | Country: pointer.ToString("UK"), 413 | }, 414 | }, 415 | wantUser: &model.User{ 416 | ID: pointer.ToString("some-test-id"), 417 | FirstName: pointer.ToString("testFirst"), 418 | LastName: pointer.ToString("testLast"), 419 | Nickname: pointer.ToString("test"), 420 | Password: pointer.ToString("test"), 421 | Email: pointer.ToString("test@test.com"), 422 | Country: pointer.ToString("UK"), 423 | CreatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 424 | UpdatedAt: pointer.ToTime(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)), 425 | }, 426 | }, 427 | } 428 | for _, tt := range tests { 429 | t.Run(tt.name, func(t *testing.T) { 430 | ctrl := gomock.NewController(t) 431 | defer ctrl.Finish() 432 | 433 | s := mocks.NewMockStore(ctrl) 434 | e := mocks.NewMockEvents(ctrl) 435 | 436 | u := users.New(s, e) 437 | require.NotNil(t, u) 438 | 439 | ctx := context.Background() 440 | 441 | s.EXPECT().UpdateUser(gomock.Any(), tt.args.user).Return(tt.wantUser, nil).Times(1) 442 | e.EXPECT().Produce(gomock.Any(), events.TopicUsers, events.UserEvent{ 443 | EventType: events.EventTypeUserUpdated, 444 | ID: *tt.wantUser.ID, 445 | User: tt.wantUser, 446 | }).Times(1) 447 | 448 | updatedUser, err := u.UpdateUser(ctx, tt.args.user) 449 | assert.NoError(t, err) 450 | assert.Equal(t, tt.wantUser, updatedUser) 451 | }) 452 | } 453 | } 454 | 455 | func TestUsers_UpdateUser_Error(t *testing.T) { 456 | type args struct { 457 | user *model.User 458 | } 459 | tests := []struct { 460 | name string 461 | args args 462 | wantErr error 463 | }{ 464 | { 465 | name: "fails", 466 | args: args{ 467 | user: &model.User{ 468 | ID: pointer.ToString("some-test-id"), 469 | FirstName: pointer.ToString("testFirst"), 470 | LastName: pointer.ToString("testLast"), 471 | Nickname: pointer.ToString("test"), 472 | Password: pointer.ToString("test"), 473 | Email: pointer.ToString("test@test.com"), 474 | Country: pointer.ToString("UK"), 475 | }, 476 | }, 477 | wantErr: errors.New("test fail"), 478 | }, 479 | } 480 | for _, tt := range tests { 481 | t.Run(tt.name, func(t *testing.T) { 482 | ctrl := gomock.NewController(t) 483 | defer ctrl.Finish() 484 | 485 | s := mocks.NewMockStore(ctrl) 486 | e := mocks.NewMockEvents(ctrl) 487 | 488 | u := users.New(s, e) 489 | require.NotNil(t, u) 490 | 491 | ctx := context.Background() 492 | 493 | s.EXPECT().UpdateUser(gomock.Any(), tt.args.user).Return(nil, tt.wantErr).Times(1) 494 | 495 | updatedUser, err := u.UpdateUser(ctx, tt.args.user) 496 | assert.ErrorIs(t, err, tt.wantErr) 497 | assert.Nil(t, updatedUser) 498 | }) 499 | } 500 | } 501 | 502 | func TestUsers_DeleteUser_Success(t *testing.T) { 503 | type args struct { 504 | id string 505 | } 506 | tests := []struct { 507 | name string 508 | args args 509 | }{ 510 | { 511 | name: "success", 512 | args: args{ 513 | id: "some-test-id", 514 | }, 515 | }, 516 | } 517 | for _, tt := range tests { 518 | t.Run(tt.name, func(t *testing.T) { 519 | ctrl := gomock.NewController(t) 520 | defer ctrl.Finish() 521 | 522 | s := mocks.NewMockStore(ctrl) 523 | e := mocks.NewMockEvents(ctrl) 524 | 525 | u := users.New(s, e) 526 | require.NotNil(t, u) 527 | 528 | ctx := context.Background() 529 | 530 | s.EXPECT().DeleteUser(gomock.Any(), tt.args.id).Return(nil).Times(1) 531 | e.EXPECT().Produce(gomock.Any(), events.TopicUsers, events.UserEvent{ 532 | EventType: events.EventTypeUserDeleted, 533 | ID: tt.args.id, 534 | }).Times(1) 535 | 536 | err := u.DeleteUser(ctx, tt.args.id) 537 | assert.NoError(t, err) 538 | }) 539 | } 540 | } 541 | 542 | func TestUsers_DeleteUser_Error(t *testing.T) { 543 | type args struct { 544 | id string 545 | } 546 | tests := []struct { 547 | name string 548 | args args 549 | wantErr error 550 | }{ 551 | { 552 | name: "fails", 553 | args: args{ 554 | id: "some-test-id", 555 | }, 556 | wantErr: errors.New("test fail"), 557 | }, 558 | } 559 | for _, tt := range tests { 560 | t.Run(tt.name, func(t *testing.T) { 561 | ctrl := gomock.NewController(t) 562 | defer ctrl.Finish() 563 | 564 | s := mocks.NewMockStore(ctrl) 565 | e := mocks.NewMockEvents(ctrl) 566 | 567 | u := users.New(s, e) 568 | require.NotNil(t, u) 569 | 570 | ctx := context.Background() 571 | 572 | s.EXPECT().DeleteUser(gomock.Any(), tt.args.id).Return(tt.wantErr).Times(1) 573 | 574 | err := u.DeleteUser(ctx, tt.args.id) 575 | assert.ErrorIs(t, err, tt.wantErr) 576 | }) 577 | } 578 | } 579 | -------------------------------------------------------------------------------- /migrations/1_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS trgm_idx_users_country; 2 | 3 | DROP INDEX IF EXISTS idx_users_country; 4 | 5 | DROP INDEX IF EXISTS trgm_idx_users_email; 6 | 7 | DROP INDEX IF EXISTS email_idx; 8 | 9 | DROP INDEX IF EXISTS trgm_idx_users_nickname; 10 | 11 | DROP INDEX IF EXISTS idx_users_nickname; 12 | 13 | DROP INDEX IF EXISTS trgm_idx_users_last_name; 14 | 15 | DROP INDEX IF EXISTS idx_users_last_name; 16 | 17 | DROP INDEX IF EXISTS trgm_idx_users_first_name; 18 | 19 | DROP INDEX IF EXISTS idx_users_first_name; 20 | 21 | DROP TABLE IF EXISTS users; 22 | 23 | DROP DOMAIN IF EXISTS email; 24 | 25 | DROP EXTENSION IF EXISTS pg_trgm; 26 | 27 | DROP EXTENSION IF EXISTS citext; 28 | 29 | DROP EXTENSION IF EXISTS "uuid-ossp"; -------------------------------------------------------------------------------- /migrations/1_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | /* allows the use of the uuid type */ 2 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 3 | 4 | /* allows regex checks on email domain */ 5 | CREATE EXTENSION IF NOT EXISTS citext; 6 | 7 | /* helps with creating partial indexes for LIKE queries */ 8 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 9 | 10 | /* attempts to validate emails using regex before being inserted to the database */ 11 | CREATE DOMAIN email AS citext CHECK ( 12 | value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS users ( 16 | id uuid DEFAULT uuid_generate_v4 (), 17 | first_name VARCHAR (255) NOT NULL, 18 | last_name VARCHAR (255) NOT NULL, 19 | nickname VARCHAR (255) NOT NULL CHECK (nickname <> ''), 20 | password VARCHAR (255) NOT NULL CHECK (password <> ''), 21 | email email NOT NULL, 22 | country VARCHAR (255) NOT NULL CHECK (country <> ''), 23 | created_at TIMESTAMP WITH TIME ZONE NOT NULL, 24 | updated_at TIMESTAMP WITH TIME ZONE NOT NULL, 25 | PRIMARY KEY (id), 26 | CONSTRAINT id_unique UNIQUE (id), 27 | CONSTRAINT nickname_unique UNIQUE (nickname), 28 | CONSTRAINT email_unique UNIQUE (email) 29 | ); 30 | 31 | /* first name index */ 32 | CREATE INDEX idx_users_first_name ON users (first_name); 33 | 34 | CREATE INDEX trgm_idx_users_first_name ON users USING gin (first_name gin_trgm_ops); 35 | 36 | /* last name index */ 37 | CREATE INDEX idx_users_last_name ON users (last_name); 38 | 39 | CREATE INDEX trgm_idx_users_last_name ON users USING gin (last_name gin_trgm_ops); 40 | 41 | /* nickname index */ 42 | CREATE UNIQUE INDEX idx_users_nickname ON users (nickname); 43 | 44 | CREATE INDEX trgm_idx_users_nickname ON users USING gin (nickname gin_trgm_ops); 45 | 46 | /* email index */ 47 | CREATE UNIQUE INDEX email_idx ON users (email); 48 | 49 | CREATE INDEX trgm_idx_users_email ON users USING gin (email gin_trgm_ops); 50 | 51 | /* country index */ 52 | CREATE INDEX idx_users_country ON users (country); 53 | 54 | CREATE INDEX trgm_idx_users_country ON users USING gin (country gin_trgm_ops); -------------------------------------------------------------------------------- /openapi/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: User API for Speakeasy template service 4 | description: The Rest Template API is an API used for instrucitonal purposes. 5 | version: 0.1.0 6 | contact: 7 | name: Speakeasy DevRel 8 | email: nolan@speakeasyapi.dev 9 | servers: 10 | - url: http://localhost:8080 11 | paths: 12 | /v1/user: 13 | post: 14 | operationId: createUserv1 15 | summary: Create user 16 | requestBody: 17 | required: true 18 | content: 19 | application/json: 20 | schema: 21 | $ref: "#/components/schemas/User" 22 | responses: 23 | "200": 24 | content: 25 | application/json: 26 | schema: 27 | $ref: "#/components/schemas/User" 28 | description: OK 29 | default: 30 | $ref: "#/components/responses/default" 31 | /v1/user/{id}: 32 | get: 33 | operationId: getUserv1 34 | summary: Get a user by ID 35 | parameters: 36 | - in: path 37 | name: id 38 | schema: 39 | type: string 40 | required: true 41 | description: Numeric ID of the user to get 42 | responses: 43 | "200": 44 | content: 45 | application/json: 46 | schema: 47 | $ref: "#/components/schemas/User" 48 | description: OK 49 | default: 50 | $ref: "#/components/responses/default" 51 | put: 52 | operationId: updateUserv1 53 | parameters: 54 | - in: path 55 | name: id 56 | schema: 57 | type: string 58 | required: true 59 | description: UserID 60 | requestBody: 61 | required: true 62 | content: 63 | application/json: 64 | schema: 65 | $ref: "#/components/schemas/User" 66 | responses: 67 | "200": 68 | content: 69 | application/json: 70 | schema: 71 | $ref: "#/components/schemas/User" 72 | description: OK 73 | default: 74 | $ref: "#/components/responses/default" 75 | delete: 76 | operationId: deleteUserv1 77 | summary: Delete a user by ID 78 | parameters: 79 | - in: path 80 | name: id 81 | schema: 82 | type: string 83 | required: true 84 | description: UserID 85 | responses: 86 | "200": 87 | content: 88 | application/json: 89 | schema: 90 | $ref: "#/components/schemas/Success" 91 | description: OK 92 | default: 93 | $ref: "#/components/responses/default" 94 | /v1/users/search: 95 | post: 96 | operationId: searchUsersv1 97 | summary: Search users 98 | requestBody: 99 | required: true 100 | content: 101 | application/json: 102 | schema: 103 | $ref: "#/components/schemas/Filters" 104 | responses: 105 | "200": 106 | content: 107 | application/json: 108 | schema: 109 | $ref: "#/components/schemas/Users" 110 | description: OK 111 | /health: 112 | get: 113 | operationId: getHealth 114 | summary: Healthcheck 115 | responses: 116 | "200": 117 | description: OK 118 | default: 119 | $ref: "#/components/responses/default" 120 | components: 121 | responses: 122 | default: 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/Error" 127 | description: Default error response 128 | schemas: 129 | Success: 130 | description: The `Status` type defines a successful response. 131 | properties: 132 | success: 133 | type: boolean 134 | type: object 135 | Error: 136 | description: The `Status` type defines a logical error model 137 | properties: 138 | message: 139 | description: A developer-facing error message. 140 | type: string 141 | status_code: 142 | description: The HTTP status code 143 | format: int32 144 | type: integer 145 | required: 146 | - message 147 | - status_code 148 | type: object 149 | Filter: 150 | description: Filters are used to query requests. 151 | properties: 152 | field: 153 | type: string 154 | matchtype: 155 | type: string 156 | value: 157 | type: string 158 | required: 159 | - field 160 | - matchtype 161 | - value 162 | type: object 163 | Filters: 164 | description: An array of filters are used to query requests. 165 | properties: 166 | filters: 167 | description: A list of filters to apply to the query. 168 | items: 169 | $ref: "#/components/schemas/Filter" 170 | type: array 171 | limit: 172 | description: The maximum number of results to return. 173 | type: integer 174 | offset: 175 | description: The offset to start the query from. 176 | type: integer 177 | required: 178 | - filters 179 | - limit 180 | - offset 181 | type: object 182 | User: 183 | description: The details of a typical user account 184 | properties: 185 | id: 186 | type: string 187 | format: uuid 188 | readOnly: true 189 | firstname: 190 | type: string 191 | lastname: 192 | type: string 193 | nickname: 194 | type: string 195 | password: 196 | type: string 197 | email: 198 | type: string 199 | format: email 200 | country: 201 | type: string 202 | createdate: 203 | format: date-time 204 | type: string 205 | readOnly: true 206 | updatedate: 207 | format: date-time 208 | type: string 209 | readOnly: true 210 | required: 211 | - firstname 212 | - lastname 213 | - nickname 214 | - password 215 | - email 216 | - country 217 | type: object 218 | Users: 219 | description: An array of users. 220 | properties: 221 | users: 222 | description: A list of users to return. 223 | items: 224 | $ref: "#/components/schemas/User" 225 | type: array 226 | required: 227 | - users 228 | type: object 229 | -------------------------------------------------------------------------------- /postman/Bootstrap Users.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "c49976e1-35ac-4c63-87cb-2bcc6278be7f", 4 | "name": "Bootstrap Users", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "21869507" 7 | }, 8 | "item": [ 9 | { 10 | "name": "CreateUsersInLoop", 11 | "event": [ 12 | { 13 | "listen": "prerequest", 14 | "script": { 15 | "exec": [ 16 | "let idx = pm.collectionVariables.get(\"userIdx\");\r", 17 | "let countries = JSON.parse(pm.collectionVariables.get(\"countries\"));\r", 18 | "let nicknames = JSON.parse(pm.collectionVariables.get(\"nicknames\"));\r", 19 | "let firstNames = JSON.parse(pm.collectionVariables.get(\"firstNames\"));\r", 20 | "let lastNames = JSON.parse(pm.collectionVariables.get(\"lastNames\"));\r", 21 | "let domains = JSON.parse(pm.collectionVariables.get(\"domains\"));\r", 22 | "\r", 23 | "pm.variables.set(\"country\", countries[idx%countries.length]);\r", 24 | "pm.variables.set(\"nickname\", nicknames[idx%nicknames.length] + \"\" + idx);\r", 25 | "pm.variables.set(\"firstName\", firstNames[idx%firstNames.length] + \"\" + idx);\r", 26 | "pm.variables.set(\"lastName\", lastNames[idx%lastNames.length] + \"\" + idx);\r", 27 | "pm.variables.set(\"email\", firstNames[idx%firstNames.length] + \"\" + idx + \"@\" + domains[idx%domains.length]);\r", 28 | "\r", 29 | "pm.variables.set(\"password\", CryptoJS.MD5(\"abcdef\").toString());" 30 | ], 31 | "type": "text/javascript" 32 | } 33 | }, 34 | { 35 | "listen": "test", 36 | "script": { 37 | "exec": [ 38 | "let idx = pm.collectionVariables.get(\"userIdx\");\r", 39 | "let numUsers = pm.collectionVariables.get(\"numUsers\");\r", 40 | "\r", 41 | "idx++;\r", 42 | "\r", 43 | "if (idx < numUsers) {\r", 44 | " pm.collectionVariables.set(\"userIdx\", idx);\r", 45 | " postman.setNextRequest(\"CreateUsersInLoop\");\r", 46 | "} else {\r", 47 | " pm.collectionVariables.set(\"userIdx\", 0);\r", 48 | " postman.setNextRequest(null);\r", 49 | "}" 50 | ], 51 | "type": "text/javascript" 52 | } 53 | } 54 | ], 55 | "request": { 56 | "method": "POST", 57 | "header": [], 58 | "body": { 59 | "mode": "raw", 60 | "raw": "{\r\n \"first_name\": \"{{firstName}}\",\r\n \"last_name\": \"{{lastName}}\", \r\n \"nickname\": \"{{nickname}}\",\r\n \"password\": \"{{password}}\",\r\n \"email\": \"{{email}}\",\r\n \"country\": \"{{country}}\"\r\n}\r\n", 61 | "options": { 62 | "raw": { 63 | "language": "json" 64 | } 65 | } 66 | }, 67 | "url": { 68 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/user", 69 | "protocol": "{{scheme}}", 70 | "host": [ 71 | "{{host}}:{{port}}" 72 | ], 73 | "path": [ 74 | "v1", 75 | "user" 76 | ] 77 | } 78 | }, 79 | "response": [] 80 | } 81 | ], 82 | "event": [ 83 | { 84 | "listen": "prerequest", 85 | "script": { 86 | "type": "text/javascript", 87 | "exec": [ 88 | "" 89 | ] 90 | } 91 | }, 92 | { 93 | "listen": "test", 94 | "script": { 95 | "type": "text/javascript", 96 | "exec": [ 97 | "" 98 | ] 99 | } 100 | } 101 | ], 102 | "variable": [ 103 | { 104 | "key": "countries", 105 | "value": "[\"UK\", \"IT\", \"US\"]", 106 | "type": "string" 107 | }, 108 | { 109 | "key": "nicknames", 110 | "value": "[\"nicky\", \"nicker\", \"nicksy\", \"nick\"]", 111 | "type": "string" 112 | }, 113 | { 114 | "key": "firstNames", 115 | "value": "[\"john\", \"judy\", \"jacob\", \"jacky\", \"jane\"]", 116 | "type": "string" 117 | }, 118 | { 119 | "key": "lastNames", 120 | "value": "[\"Smith\", \"Johnson\", \"Williams\", \"Brown\", \"Lee\", \"Clark\"]", 121 | "type": "string" 122 | }, 123 | { 124 | "key": "domains", 125 | "value": "[\"gmail.com\", \"hotmail.com\", \"yahoo.com\"]", 126 | "type": "string" 127 | }, 128 | { 129 | "key": "numUsers", 130 | "value": "100", 131 | "type": "string" 132 | }, 133 | { 134 | "key": "userIdx", 135 | "value": "0", 136 | "type": "string" 137 | } 138 | ] 139 | } -------------------------------------------------------------------------------- /postman/Users.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "5bbd2348-ce8e-4637-9310-e5acff3a49c0", 4 | "name": "Users", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "21869507" 7 | }, 8 | "item": [ 9 | { 10 | "name": "CreateUser", 11 | "event": [ 12 | { 13 | "listen": "prerequest", 14 | "script": { 15 | "exec": [ 16 | "pm.variables.set(\"password\", CryptoJS.MD5(\"abcdef\").toString());" 17 | ], 18 | "type": "text/javascript" 19 | } 20 | }, 21 | { 22 | "listen": "test", 23 | "script": { 24 | "exec": [ 25 | "var jsonData = JSON.parse(responseBody);\r", 26 | "postman.setEnvironmentVariable(\"user_id\", jsonData.data.id);" 27 | ], 28 | "type": "text/javascript" 29 | } 30 | } 31 | ], 32 | "request": { 33 | "method": "POST", 34 | "header": [], 35 | "body": { 36 | "mode": "raw", 37 | "raw": "{\r\n \"first_name\": \"Alice\",\r\n \"last_name\": \"Bob\", \r\n \"nickname\": \"AB123\",\r\n \"password\": \"{{password}}\",\r\n \"email\": \"alice@bob.com\",\r\n \"country\": \"UK\"\r\n}\r\n", 38 | "options": { 39 | "raw": { 40 | "language": "json" 41 | } 42 | } 43 | }, 44 | "url": { 45 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/user", 46 | "protocol": "{{scheme}}", 47 | "host": [ 48 | "{{host}}:{{port}}" 49 | ], 50 | "path": [ 51 | "v1", 52 | "user" 53 | ] 54 | } 55 | }, 56 | "response": [] 57 | }, 58 | { 59 | "name": "SearchUsers", 60 | "event": [ 61 | { 62 | "listen": "prerequest", 63 | "script": { 64 | "exec": [ 65 | "" 66 | ], 67 | "type": "text/javascript" 68 | } 69 | }, 70 | { 71 | "listen": "test", 72 | "script": { 73 | "exec": [ 74 | "" 75 | ], 76 | "type": "text/javascript" 77 | } 78 | } 79 | ], 80 | "request": { 81 | "method": "POST", 82 | "header": [], 83 | "body": { 84 | "mode": "raw", 85 | "raw": "{\r\n \"filters\": [\r\n {\r\n \"field\": \"country\",\r\n \"match_type\": \"=\",\r\n \"value\": \"UK\"\r\n }\r\n ],\r\n \"offset\": 0,\r\n \"limit\": 10\r\n}\r\n", 86 | "options": { 87 | "raw": { 88 | "language": "json" 89 | } 90 | } 91 | }, 92 | "url": { 93 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/users/search", 94 | "protocol": "{{scheme}}", 95 | "host": [ 96 | "{{host}}:{{port}}" 97 | ], 98 | "path": [ 99 | "v1", 100 | "users", 101 | "search" 102 | ] 103 | } 104 | }, 105 | "response": [] 106 | }, 107 | { 108 | "name": "UpdateUser", 109 | "event": [ 110 | { 111 | "listen": "prerequest", 112 | "script": { 113 | "exec": [ 114 | "pm.variables.set(\"password\", CryptoJS.MD5(\"abcdef\").toString());" 115 | ], 116 | "type": "text/javascript" 117 | } 118 | }, 119 | { 120 | "listen": "test", 121 | "script": { 122 | "exec": [ 123 | "" 124 | ], 125 | "type": "text/javascript" 126 | } 127 | } 128 | ], 129 | "request": { 130 | "method": "PUT", 131 | "header": [], 132 | "body": { 133 | "mode": "raw", 134 | "raw": "{\r\n \"first_name\": \"Alice\",\r\n \"last_name\": \"Bob\", \r\n \"nickname\": \"AB1234\",\r\n \"password\": \"{{password}}\",\r\n \"email\": \"alice@bob.com\",\r\n \"country\": \"UK\"\r\n}\r\n", 135 | "options": { 136 | "raw": { 137 | "language": "json" 138 | } 139 | } 140 | }, 141 | "url": { 142 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/user/{{user_id}}", 143 | "protocol": "{{scheme}}", 144 | "host": [ 145 | "{{host}}:{{port}}" 146 | ], 147 | "path": [ 148 | "v1", 149 | "user", 150 | "{{user_id}}" 151 | ] 152 | } 153 | }, 154 | "response": [] 155 | }, 156 | { 157 | "name": "GetUser", 158 | "event": [ 159 | { 160 | "listen": "prerequest", 161 | "script": { 162 | "exec": [ 163 | "" 164 | ], 165 | "type": "text/javascript" 166 | } 167 | }, 168 | { 169 | "listen": "test", 170 | "script": { 171 | "exec": [ 172 | "" 173 | ], 174 | "type": "text/javascript" 175 | } 176 | } 177 | ], 178 | "protocolProfileBehavior": { 179 | "disableBodyPruning": true 180 | }, 181 | "request": { 182 | "method": "GET", 183 | "header": [], 184 | "body": { 185 | "mode": "raw", 186 | "raw": "", 187 | "options": { 188 | "raw": { 189 | "language": "json" 190 | } 191 | } 192 | }, 193 | "url": { 194 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/user/{{user_id}}", 195 | "protocol": "{{scheme}}", 196 | "host": [ 197 | "{{host}}:{{port}}" 198 | ], 199 | "path": [ 200 | "v1", 201 | "user", 202 | "{{user_id}}" 203 | ] 204 | } 205 | }, 206 | "response": [] 207 | }, 208 | { 209 | "name": "DeleteUser", 210 | "event": [ 211 | { 212 | "listen": "prerequest", 213 | "script": { 214 | "exec": [ 215 | "" 216 | ], 217 | "type": "text/javascript" 218 | } 219 | } 220 | ], 221 | "request": { 222 | "method": "DELETE", 223 | "header": [], 224 | "body": { 225 | "mode": "raw", 226 | "raw": "", 227 | "options": { 228 | "raw": { 229 | "language": "json" 230 | } 231 | } 232 | }, 233 | "url": { 234 | "raw": "{{scheme}}://{{host}}:{{port}}/v1/user/{{user_id}}", 235 | "protocol": "{{scheme}}", 236 | "host": [ 237 | "{{host}}:{{port}}" 238 | ], 239 | "path": [ 240 | "v1", 241 | "user", 242 | "{{user_id}}" 243 | ] 244 | } 245 | }, 246 | "response": [] 247 | }, 248 | { 249 | "name": "Health", 250 | "request": { 251 | "method": "GET", 252 | "header": [], 253 | "url": { 254 | "raw": "{{scheme}}://{{host}}:{{port}}/health", 255 | "protocol": "{{scheme}}", 256 | "host": [ 257 | "{{host}}:{{port}}" 258 | ], 259 | "path": [ 260 | "health" 261 | ] 262 | } 263 | }, 264 | "response": [] 265 | } 266 | ] 267 | } --------------------------------------------------------------------------------