├── .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 |
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 | }
--------------------------------------------------------------------------------