├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── integration.yml │ ├── lint.yml │ └── osv-scanner.yml ├── LICENSE.md ├── README.md ├── go.mod ├── go.sum ├── internal ├── api │ ├── api.go │ ├── grpc.go │ └── http.go ├── apiv1 │ ├── api.proto │ ├── apipb │ │ ├── api.pb.go │ │ └── api_grpc.pb.go │ └── gen.go ├── database │ ├── database.go │ ├── database_test.go │ └── interface.go ├── inventory │ ├── helper_test.go │ ├── inventory.go │ ├── inventory_test.go │ ├── mock_db_test.go │ ├── review.go │ ├── review_test.go │ ├── service.go │ └── service_test.go ├── postgres │ ├── helper_test.go │ ├── postgres.go │ └── postgres_test.go └── telemetry │ ├── telemetry.go │ ├── telemetry_test.go │ └── telemetrytest │ ├── telemetrytest.go │ └── telemetrytest_test.go ├── main.go ├── migrations └── 001_initial_schema.sql └── scripts ├── ci-lint-fmt.sh ├── ci-lint.sh └── coverage.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: henvic 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: Code Scanning 2 | # Source: https://github.com/cli/cli/blob/trunk/.github/workflows/codeql.yml 3 | 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | # The branches below must be a subset of the branches above 10 | branches: [ "main" ] 11 | schedule: 12 | - cron: '32 13 * * 4' 13 | 14 | jobs: 15 | CodeQL-Build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: read 19 | contents: read 20 | security-events: write 21 | 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@v4 25 | 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | with: 29 | languages: go 30 | 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v3 33 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v4 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4 15 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | pull_request: 6 | types: [opened, synchronize, reopened, ready_for_review] 7 | # The branches below must be a subset of the branches above 8 | branches: [ "main" ] 9 | permissions: 10 | contents: read 11 | pull-requests: read 12 | jobs: 13 | # Reference: https://docs.github.com/en/actions/guides/creating-postgresql-service-containers 14 | postgres-test: 15 | runs-on: ubuntu-latest 16 | services: 17 | postgres: 18 | image: postgres 19 | env: 20 | POSTGRES_USER: runner 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_DB: test_pgxtutorial 23 | options: >- 24 | --name postgres 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | ports: 30 | # Maps tcp port 5432 on service container to the host 31 | - 5432:5432 32 | env: 33 | INTEGRATION_TESTDB: true 34 | PGHOST: localhost 35 | PGUSER: runner 36 | PGPASSWORD: postgres 37 | PGDATABASE: test_pgxtutorial 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-go@v5 41 | with: 42 | go-version: '1.24.x' 43 | - name: Verify dependencies 44 | run: | 45 | go mod verify 46 | go mod download 47 | - name: Run Postgres tests 48 | run: go test -v -race -count 1 -shuffle on -covermode atomic -coverprofile=profile.cov ./... 49 | - name: Code coverage 50 | if: ${{ github.event_name != 'pull_request' }} 51 | uses: shogo82148/actions-goveralls@v1 52 | with: 53 | path-to-profile: profile.cov 54 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | types: [opened, synchronize, reopened, ready_for_review] 8 | # The branches below must be a subset of the branches above 9 | branches: [ "main" ] 10 | 11 | jobs: 12 | 13 | lint: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | 18 | - name: Set up Go 1.x 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: "1.24.x" 22 | 23 | - name: Check out code 24 | uses: actions/checkout@v4 25 | 26 | - name: Verify dependencies 27 | run: | 28 | go mod verify 29 | go mod download 30 | 31 | - name: Run checks 32 | run: ./scripts/ci-lint.sh 33 | -------------------------------------------------------------------------------- /.github/workflows/osv-scanner.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities, 7 | # in addition to a PR check which fails if new vulnerabilities are introduced. 8 | # 9 | # For more examples and options, including how to ignore specific vulnerabilities, 10 | # see https://google.github.io/osv-scanner/github-action/ 11 | 12 | name: OSV-Scanner 13 | 14 | on: 15 | pull_request: 16 | branches: [ "main" ] 17 | merge_group: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '42 16 * * 1' 21 | push: 22 | branches: [ "main" ] 23 | 24 | permissions: 25 | # Required to upload SARIF file to CodeQL. See: https://github.com/github/codeql-action/issues/2117 26 | actions: read 27 | # Require writing security events to upload SARIF file to security tab 28 | security-events: write 29 | # Read commit contents 30 | contents: read 31 | 32 | jobs: 33 | scan-scheduled: 34 | if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} 35 | uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.9.1" 36 | scan-pr: 37 | if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} 38 | uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v1.9.1" 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Henrique Vicente 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgxtutorial 2 | [![GoDoc](https://godoc.org/github.com/henvic/pgxtutorial?status.svg)](https://godoc.org/github.com/henvic/pgxtutorial) [![Build Status](https://github.com/henvic/pgxtutorial/workflows/Integration/badge.svg)](https://github.com/henvic/pgxtutorial/actions?query=workflow%3AIntegration) [![Coverage Status](https://coveralls.io/repos/henvic/pgxtutorial/badge.svg)](https://coveralls.io/r/henvic/pgxtutorial) 3 | 4 | This is an accompanying repository of the article [Back to basics: Writing an application using Go and PostgreSQL](https://henvic.dev/posts/go-postgres) by [Henrique Vicente](https://henvic.dev/). Feel free to open issues to ask any questions or comment on anything. 5 | 6 | ## Environment variables 7 | pgxtutorial uses the following environment variables: 8 | 9 | | Environment Variable | Description | 10 | | - | - | 11 | | PostgreSQL environment variables | Please check https://www.postgresql.org/docs/current/libpq-envars.html | 12 | | INTEGRATION_TESTDB | When running go test, database tests will only run if `INTEGRATION_TESTDB=true` | 13 | | OTEL_EXPORTER | When OTEL_EXPORTER=stdout or OTEL_EXPORTER=otel, telemetry is exported | 14 | 15 | ## tl;dr 16 | To play with it install [Go](https://go.dev/) on your system. 17 | You'll need to connect to a [PostgreSQL](https://www.postgresql.org/) database. 18 | You can check if a connection is working by calling `psql`. 19 | 20 | To run tests: 21 | 22 | ```sh 23 | # Run all tests passing INTEGRATION_TESTDB explicitly 24 | $ INTEGRATION_TESTDB=true go test -v ./... 25 | ``` 26 | 27 | To run application: 28 | 29 | ```sh 30 | # Create a database 31 | $ psql -c "CREATE DATABASE pgxtutorial;" 32 | # Set the environment variable PGDATABASE 33 | $ export PGDATABASE=pgxtutorial 34 | # Run migrations 35 | $ tern migrate -m ./migrations 36 | # Execute application 37 | $ go run . 38 | 2021/11/22 07:21:21 HTTP server listening at localhost:8080 39 | 2021/11/22 07:21:21 gRPC server listening at 127.0.0.1:8082 40 | ``` 41 | 42 | You could also 43 | ``` 44 | go install github.com/henvic/pgxtutorial@latest 45 | ``` 46 | to just get the `pgxtutorial` binary. 47 | 48 | ## See also 49 | * [pgtools](https://github.com/henvic/pgtools/) 50 | * [pgq](https://github.com/henvic/pgq) 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/henvic/pgxtutorial 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/exaring/otelpgx v0.9.0 7 | github.com/felixge/fgprof v0.9.5 8 | github.com/google/go-cmp v0.7.0 9 | github.com/henvic/pgtools v0.3.0 10 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 11 | github.com/jackc/pgx/v5 v5.7.4 12 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 13 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 14 | go.opentelemetry.io/otel v1.35.0 15 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 16 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 17 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 18 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 19 | go.opentelemetry.io/otel/metric v1.35.0 20 | go.opentelemetry.io/otel/sdk v1.35.0 21 | go.opentelemetry.io/otel/sdk/metric v1.35.0 22 | go.opentelemetry.io/otel/trace v1.35.0 23 | go.uber.org/automaxprocs v1.6.0 24 | go.uber.org/mock v0.5.1 25 | google.golang.org/grpc v1.72.0 26 | google.golang.org/protobuf v1.36.6 27 | ) 28 | 29 | require ( 30 | cloud.google.com/go v0.120.1 // indirect 31 | cloud.google.com/go/ai v0.10.2 // indirect 32 | cloud.google.com/go/auth v0.16.1 // indirect 33 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 34 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 35 | cloud.google.com/go/longrunning v0.6.7 // indirect 36 | dario.cat/mergo v1.0.1 // indirect 37 | github.com/BurntSushi/toml v1.5.0 // indirect 38 | github.com/Masterminds/goutils v1.1.1 // indirect 39 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 40 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 41 | github.com/ccojocar/zxcvbn-go v1.0.4 // indirect 42 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 43 | github.com/felixge/httpsnoop v1.0.4 // indirect 44 | github.com/go-logr/logr v1.4.2 // indirect 45 | github.com/go-logr/stdr v1.2.2 // indirect 46 | github.com/google/generative-ai-go v0.19.0 // indirect 47 | github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect 48 | github.com/google/s2a-go v0.1.9 // indirect 49 | github.com/google/uuid v1.6.0 // indirect 50 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 51 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 52 | github.com/gookit/color v1.5.4 // indirect 53 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 54 | github.com/huandu/xstrings v1.5.0 // indirect 55 | github.com/jackc/pgpassfile v1.0.0 // indirect 56 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 57 | github.com/jackc/puddle/v2 v2.2.2 // indirect 58 | github.com/jackc/tern/v2 v2.3.2 // indirect 59 | github.com/mitchellh/copystructure v1.2.0 // indirect 60 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 61 | github.com/securego/gosec/v2 v2.22.3 // indirect 62 | github.com/shopspring/decimal v1.4.0 // indirect 63 | github.com/spf13/cast v1.7.1 // indirect 64 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 65 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 67 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 68 | golang.org/x/crypto v0.37.0 // indirect 69 | golang.org/x/exp/typeparams v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 70 | golang.org/x/mod v0.24.0 // indirect 71 | golang.org/x/net v0.39.0 // indirect 72 | golang.org/x/oauth2 v0.29.0 // indirect 73 | golang.org/x/sync v0.13.0 // indirect 74 | golang.org/x/sys v0.32.0 // indirect 75 | golang.org/x/telemetry v0.0.0-20250417124945-06ef541f3fa3 // indirect 76 | golang.org/x/text v0.24.0 // indirect 77 | golang.org/x/time v0.11.0 // indirect 78 | golang.org/x/tools v0.32.0 // indirect 79 | golang.org/x/vuln v1.1.4 // indirect 80 | google.golang.org/api v0.230.0 // indirect 81 | google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197 // indirect 82 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 // indirect 83 | gopkg.in/yaml.v3 v3.0.1 // indirect 84 | honnef.co/go/tools v0.6.1 // indirect 85 | ) 86 | 87 | tool ( 88 | github.com/securego/gosec/v2/cmd/gosec 89 | golang.org/x/vuln/cmd/govulncheck 90 | honnef.co/go/tools/cmd/staticcheck 91 | ) 92 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.120.1 h1:Z+5V7yd383+9617XDCyszmK5E4wJRJL+tquMfDj9hLM= 2 | cloud.google.com/go v0.120.1/go.mod h1:56Vs7sf/i2jYM6ZL9NYlC82r04PThNcPS5YgFmb0rp8= 3 | cloud.google.com/go/ai v0.10.2 h1:5NHzmZlRs+3kvlsVdjT0cTnLrjQdROJ/8VOljVfs+8o= 4 | cloud.google.com/go/ai v0.10.2/go.mod h1:xZuZuE9d3RgsR132meCnPadiU9XV0qXjpLr+P4J46eE= 5 | cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= 6 | cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= 7 | cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 8 | cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 9 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 10 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 11 | cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= 12 | cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= 13 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 14 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 15 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 16 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 17 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 18 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 19 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 20 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 21 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 22 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 23 | github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= 24 | github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= 25 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 26 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 27 | github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= 28 | github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= 29 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 30 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 31 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 32 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/exaring/otelpgx v0.9.0 h1:Bo0RIhBNrzLlVzih46qBy/KQRvRs9vwRbgT/fE363NM= 37 | github.com/exaring/otelpgx v0.9.0/go.mod h1:ANkRZDfgfmN6yJS1xKMkshbnsHO8at5sYwtVEYOX8hc= 38 | github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= 39 | github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= 40 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 41 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 42 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 43 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 44 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 45 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 46 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 47 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 48 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 49 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 50 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 51 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 52 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 53 | github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= 54 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 55 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 56 | github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= 57 | github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= 58 | github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= 59 | github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= 60 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 61 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 62 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= 63 | github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4= 64 | github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= 65 | github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= 66 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 67 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 68 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 69 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 70 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 71 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= 72 | github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 73 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 74 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 75 | github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= 76 | github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= 77 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 78 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 79 | github.com/henvic/pgtools v0.3.0 h1:LK5wtpoKWDQfWTK6uB4pI1znIc5ceDyVIHN2fyeMupA= 80 | github.com/henvic/pgtools v0.3.0/go.mod h1:SSJdu3BvSSG1YLab4BLRMEl4LsQ4bcen3K3M1LbTNAs= 81 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 82 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 83 | github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= 84 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= 85 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= 86 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 87 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 88 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 89 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 90 | github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= 91 | github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 92 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 93 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 94 | github.com/jackc/tern/v2 v2.3.2 h1:/d3ML6jyQGDDtvKCGnHp8HY0swh86VcNvTMkC65+frk= 95 | github.com/jackc/tern/v2 v2.3.2/go.mod h1:cJYmwlpXLs3vBtbkfKdgoZL0G96mH56W+fugKx+k3zw= 96 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 97 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 98 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 99 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 100 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 101 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 102 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 103 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 104 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 105 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 106 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 107 | github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= 108 | github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= 109 | github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= 110 | github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 111 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 112 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 113 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 114 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 115 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 116 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 117 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 118 | github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc= 119 | github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k= 120 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 121 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 122 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 123 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 124 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 125 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 126 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 127 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 128 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 129 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 130 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 131 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 132 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 133 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 134 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 135 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 136 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 137 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 138 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= 139 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= 140 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= 141 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 142 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 143 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 144 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= 145 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= 146 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= 147 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= 148 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= 149 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= 150 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= 151 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= 152 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= 153 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= 154 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 155 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 156 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 157 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 158 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 159 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 160 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 161 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 162 | go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= 163 | go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= 164 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 165 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 166 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 167 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 168 | go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= 169 | go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 170 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 171 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 172 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 173 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 174 | golang.org/x/exp/typeparams v0.0.0-20250408133849-7e4ce0ab07d0 h1:oMe07YcizemJ09rs2kRkFYAp0pt4e1lYLwPWiEGMpXE= 175 | golang.org/x/exp/typeparams v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= 176 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 177 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 178 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 179 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 180 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 181 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 182 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 183 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 184 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 185 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 186 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 187 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 188 | golang.org/x/telemetry v0.0.0-20250417124945-06ef541f3fa3 h1:RXY2+rSHXvxO2Y+gKrPjYVaEoGOqh3VEXFhnWAt1Irg= 189 | golang.org/x/telemetry v0.0.0-20250417124945-06ef541f3fa3/go.mod h1:RoaXAWDwS90j6FxVKwJdBV+0HCU+llrKUGgJaxiKl6M= 190 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 191 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 192 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 193 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 194 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 195 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 196 | golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= 197 | golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= 198 | google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= 199 | google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= 200 | google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197 h1:9DuBh3k1jUho2DHdxH+kbJwthIAq02vGvZNrD2ggF+Y= 201 | google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ= 202 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 h1:29cjnHVylHwTzH66WfFZqgSQgnxzvWE+jvBwpZCLRxY= 203 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 204 | google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= 205 | google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 206 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 207 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 208 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 209 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 210 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 211 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 212 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 213 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 214 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 215 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= 216 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | sync "sync" 11 | "time" 12 | 13 | "github.com/henvic/pgxtutorial/internal/apiv1/apipb" 14 | "github.com/henvic/pgxtutorial/internal/inventory" 15 | "github.com/henvic/pgxtutorial/internal/telemetry" 16 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 17 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 18 | "go.opentelemetry.io/otel/metric" 19 | "go.opentelemetry.io/otel/propagation" 20 | "go.opentelemetry.io/otel/trace" 21 | "google.golang.org/grpc" 22 | "google.golang.org/grpc/health" 23 | "google.golang.org/grpc/health/grpc_health_v1" 24 | "google.golang.org/grpc/reflection" 25 | ) 26 | 27 | // Server for the API. 28 | type Server struct { 29 | HTTPAddress string 30 | GRPCAddress string 31 | ProbeAddress string 32 | 33 | Log *slog.Logger 34 | Tracer trace.TracerProvider 35 | Meter metric.MeterProvider 36 | Propagator propagation.TextMapPropagator 37 | 38 | Inventory *inventory.Service 39 | 40 | grpc *grpcServer 41 | http *httpServer 42 | probe *probeServer 43 | 44 | stopFn sync.Once 45 | } 46 | 47 | // Run starts the HTTP and gRPC servers. 48 | func (s *Server) Run(ctx context.Context) (err error) { 49 | var ec = make(chan error, 3) // gRPC, HTTP, debug servers 50 | ctx, cancel := context.WithCancel(ctx) 51 | 52 | tel := telemetry.NewProvider( 53 | s.Log, 54 | s.Tracer.Tracer("api"), 55 | s.Meter.Meter("api"), 56 | s.Propagator) 57 | 58 | s.grpc = &grpcServer{ 59 | inventory: s.Inventory, 60 | tel: *tel, 61 | } 62 | s.http = &httpServer{ 63 | inventory: s.Inventory, 64 | tel: *tel, 65 | } 66 | s.probe = &probeServer{ 67 | tel: *tel, 68 | } 69 | 70 | go func() { 71 | err := s.grpc.Run(ctx, s.GRPCAddress, otelgrpc.WithMeterProvider(s.Meter), otelgrpc.WithTracerProvider(s.Tracer), otelgrpc.WithPropagators(s.Propagator)) 72 | if err != nil { 73 | err = fmt.Errorf("gRPC server error: %w", err) 74 | } 75 | ec <- err 76 | }() 77 | go func() { 78 | err := s.http.Run(ctx, s.HTTPAddress, otelhttp.WithMeterProvider(s.Meter), otelhttp.WithTracerProvider(s.Tracer), otelhttp.WithPropagators(s.Propagator)) 79 | if err != nil { 80 | err = fmt.Errorf("HTTP server error: %w", err) 81 | } 82 | ec <- err 83 | }() 84 | go func() { 85 | err := s.probe.Run(ctx, s.ProbeAddress) 86 | if err != nil { 87 | err = fmt.Errorf("probe server error: %w", err) 88 | } 89 | ec <- err 90 | }() 91 | 92 | // Wait for the services to exit. 93 | var es []error 94 | for i := 0; i < cap(ec); i++ { 95 | if err := <-ec; err != nil { 96 | es = append(es, err) 97 | // If one of the services returns by a reason other than parent context canceled, 98 | // try to gracefully shutdown the other services to shutdown everything, 99 | // with the goal of replacing this service with a new healthy one. 100 | // NOTE: It might be a slightly better strategy to announce it as unfit for handling traffic, 101 | // while leaving the program running for debugging. 102 | if ctx.Err() == nil { 103 | s.Shutdown(context.Background()) 104 | } 105 | } 106 | } 107 | cancel() 108 | return errors.Join(es...) 109 | } 110 | 111 | // Shutdown HTTP and gRPC servers. 112 | func (s *Server) Shutdown(ctx context.Context) { 113 | // Don't try to start a graceful shutdown multiple times. 114 | s.stopFn.Do(func() { 115 | s.http.Shutdown(ctx) 116 | s.grpc.Shutdown(ctx) 117 | s.probe.Shutdown(ctx) 118 | }) 119 | } 120 | 121 | type httpServer struct { 122 | inventory *inventory.Service 123 | tel telemetry.Provider 124 | 125 | middleware func(http.Handler) http.Handler 126 | http *http.Server 127 | } 128 | 129 | // Run HTTP server. 130 | func (s *httpServer) Run(ctx context.Context, address string, otelOptions ...otelhttp.Option) error { 131 | handler := NewHTTPServer(s.inventory, s.tel) 132 | 133 | // Inject middleware, if the middleware field is set. 134 | if s.middleware != nil { 135 | handler = s.middleware(handler) 136 | } 137 | 138 | s.http = &http.Server{ 139 | Addr: address, 140 | Handler: otelhttp.NewHandler(handler, "api", otelOptions...), 141 | 142 | ReadHeaderTimeout: 5 * time.Second, // mitigate risk of Slowloris Attack 143 | } 144 | s.tel.Logger().Info("HTTP server listening", slog.Any("address", address)) 145 | if err := s.http.ListenAndServe(); err != http.ErrServerClosed { 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | // Shutdown HTTP server. 152 | func (s *httpServer) Shutdown(ctx context.Context) { 153 | s.tel.Logger().Info("shutting down HTTP server") 154 | if s.http != nil { 155 | if err := s.http.Shutdown(ctx); err != nil { 156 | s.tel.Logger().Error("graceful shutdown of HTTP server failed", slog.Any("error", err)) 157 | } 158 | } 159 | } 160 | 161 | type grpcServer struct { 162 | inventory *inventory.Service 163 | grpc *grpc.Server 164 | health *health.Server 165 | tel telemetry.Provider 166 | } 167 | 168 | // Run gRPC server. 169 | func (s *grpcServer) Run(ctx context.Context, address string, oo ...otelgrpc.Option) error { 170 | s.health = health.NewServer() 171 | 172 | var lc net.ListenConfig 173 | lis, err := lc.Listen(ctx, "tcp", address) 174 | if err != nil { 175 | return fmt.Errorf("failed to listen: %w", err) 176 | } 177 | s.grpc = grpc.NewServer( 178 | grpc.StatsHandler(otelgrpc.NewServerHandler(oo...)), 179 | ) 180 | reflection.Register(s.grpc) 181 | grpc_health_v1.RegisterHealthServer(s.grpc, s.health) 182 | apipb.RegisterInventoryServer(s.grpc, &InventoryGRPC{ 183 | Inventory: s.inventory, 184 | }) 185 | s.health.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) 186 | s.tel.Logger().Info("gRPC server listening", slog.Any("address", lis.Addr())) 187 | if err := s.grpc.Serve(lis); err != nil { 188 | return fmt.Errorf("failed to serve: %w", err) 189 | } 190 | return nil 191 | } 192 | 193 | // Shutdown gRPC server. 194 | func (s *grpcServer) Shutdown(ctx context.Context) { 195 | s.tel.Logger().Info("shutting down gRPC server") 196 | s.health.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING) 197 | done := make(chan struct{}, 1) 198 | go func() { 199 | if s.grpc != nil { 200 | s.grpc.GracefulStop() 201 | } 202 | done <- struct{}{} 203 | }() 204 | select { 205 | case <-done: 206 | case <-ctx.Done(): 207 | if s.grpc != nil { 208 | s.grpc.Stop() 209 | } 210 | s.tel.Logger().Error("graceful shutdown of gRPC server failed") 211 | } 212 | } 213 | 214 | // probeServer runs an HTTP server exposing pprof endpoints. 215 | type probeServer struct { 216 | http *http.Server 217 | tel telemetry.Provider 218 | } 219 | 220 | // Run HTTP pprof server. 221 | func (s *probeServer) Run(ctx context.Context, address string) error { 222 | // Use http.DefaultServeMux, rather than defining a custom mux. 223 | s.http = &http.Server{ 224 | Addr: address, 225 | 226 | ReadHeaderTimeout: 5 * time.Second, // mitigate risk of Slowloris Attack 227 | } 228 | s.tel.Logger().Info("Probe server listening", slog.Any("address", address)) 229 | if err := s.http.ListenAndServe(); err != http.ErrServerClosed { 230 | return err 231 | } 232 | return nil 233 | } 234 | 235 | // Shutdown HTTP server. 236 | func (s *probeServer) Shutdown(ctx context.Context) { 237 | s.tel.Logger().Info("shutting down pprof server") 238 | if s.http != nil { 239 | if err := s.http.Shutdown(ctx); err != nil { 240 | s.tel.Logger().Error("graceful shutdown of pprof server failed", slog.Any("error", err)) 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /internal/api/grpc.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/henvic/pgxtutorial/internal/apiv1/apipb" 8 | "github.com/henvic/pgxtutorial/internal/inventory" 9 | codes "google.golang.org/grpc/codes" 10 | status "google.golang.org/grpc/status" 11 | "google.golang.org/protobuf/proto" 12 | ) 13 | 14 | // InventoryGRPC services. 15 | type InventoryGRPC struct { 16 | apipb.UnimplementedInventoryServer 17 | Inventory *inventory.Service 18 | } 19 | 20 | func (i *InventoryGRPC) SearchProducts(ctx context.Context, req *apipb.SearchProductsRequest) (*apipb.SearchProductsResponse, error) { 21 | params := inventory.SearchProductsParams{ 22 | QueryString: req.GetQueryString(), 23 | MinPrice: int(req.GetMinPrice()), 24 | MaxPrice: int(req.GetMaxPrice()), 25 | } 26 | page, pp := int(req.GetPage()), 50 27 | 28 | params.Pagination = inventory.Pagination{ 29 | Limit: pp * page, 30 | Offset: pp * (page - 1), 31 | } 32 | products, err := i.Inventory.SearchProducts(ctx, params) 33 | if err != nil { 34 | return nil, grpcAPIError(err) 35 | } 36 | 37 | items := []*apipb.Product{} 38 | for _, p := range products.Items { 39 | items = append(items, apipb.Product_builder{ 40 | Id: proto.String(p.ID), 41 | Price: proto.Int64(int64(p.Price)), 42 | Name: proto.String(p.Name), 43 | Description: proto.String(p.Description), 44 | }.Build()) 45 | } 46 | return apipb.SearchProductsResponse_builder{ 47 | Total: proto.Int32(int32(products.Total)), 48 | Items: items, 49 | }.Build(), nil 50 | } 51 | 52 | // CreateProduct on the inventory. 53 | func (i *InventoryGRPC) CreateProduct(ctx context.Context, req *apipb.CreateProductRequest) (*apipb.CreateProductResponse, error) { 54 | if err := i.Inventory.CreateProduct(ctx, inventory.CreateProductParams{ 55 | ID: req.GetId(), 56 | Name: req.GetName(), 57 | Description: req.GetDescription(), 58 | Price: int(req.GetPrice()), 59 | }); err != nil { 60 | return nil, grpcAPIError(err) 61 | } 62 | return &apipb.CreateProductResponse{}, nil 63 | } 64 | 65 | // UpdateProduct on the inventory. 66 | func (i *InventoryGRPC) UpdateProduct(ctx context.Context, req *apipb.UpdateProductRequest) (*apipb.UpdateProductResponse, error) { 67 | params := inventory.UpdateProductParams{ 68 | ID: req.GetId(), 69 | } 70 | if req.HasName() { 71 | params.Name = proto.String(req.GetName()) 72 | } 73 | if req.HasDescription() { 74 | params.Description = proto.String(req.GetDescription()) 75 | } 76 | if req.HasPrice() { 77 | price := int(req.GetPrice()) 78 | params.Price = &price 79 | } 80 | if err := i.Inventory.UpdateProduct(ctx, params); err != nil { 81 | return nil, grpcAPIError(err) 82 | } 83 | return &apipb.UpdateProductResponse{}, nil 84 | } 85 | 86 | // DeleteProduct on the inventory. 87 | func (i *InventoryGRPC) DeleteProduct(ctx context.Context, req *apipb.DeleteProductRequest) (*apipb.DeleteProductResponse, error) { 88 | if err := i.Inventory.DeleteProduct(ctx, req.GetId()); err != nil { 89 | return nil, grpcAPIError(err) 90 | } 91 | return &apipb.DeleteProductResponse{}, nil 92 | } 93 | 94 | // GetProduct on the inventory. 95 | func (i *InventoryGRPC) GetProduct(ctx context.Context, req *apipb.GetProductRequest) (*apipb.GetProductResponse, error) { 96 | product, err := i.Inventory.GetProduct(ctx, req.GetId()) 97 | if err != nil { 98 | return nil, grpcAPIError(err) 99 | } 100 | if product == nil { 101 | return nil, status.Error(codes.NotFound, "product not found") 102 | } 103 | return apipb.GetProductResponse_builder{ 104 | Id: proto.String(product.ID), 105 | Price: proto.Int64(int64(product.Price)), 106 | Name: proto.String(product.Name), 107 | Description: proto.String(product.Description), 108 | CreatedAt: proto.String(product.CreatedAt.String()), 109 | ModifiedAt: proto.String(product.ModifiedAt.String()), 110 | }.Build(), nil 111 | } 112 | 113 | // CreateProductReview on the inventory. 114 | func (i *InventoryGRPC) CreateProductReview(ctx context.Context, req *apipb.CreateProductReviewRequest) (*apipb.CreateProductReviewResponse, error) { 115 | id, err := i.Inventory.CreateProductReview(ctx, inventory.CreateProductReviewParams{ 116 | ProductID: req.GetProductId(), 117 | ReviewerID: req.GetReviewerId(), 118 | Score: req.GetScore(), 119 | Title: req.GetTitle(), 120 | Description: req.GetDescription(), 121 | }) 122 | if err != nil { 123 | return nil, grpcAPIError(err) 124 | } 125 | return apipb.CreateProductReviewResponse_builder{ 126 | Id: proto.String(id), 127 | }.Build(), nil 128 | } 129 | 130 | func (i *InventoryGRPC) UpdateProductReview(ctx context.Context, req *apipb.UpdateProductReviewRequest) (*apipb.UpdateProductReviewResponse, error) { 131 | params := inventory.UpdateProductReviewParams{ 132 | ID: req.GetId(), 133 | Title: proto.String(req.GetTitle()), 134 | Description: proto.String(req.GetDescription()), 135 | } 136 | if req.HasScore() { 137 | params.Score = proto.Int32(req.GetScore()) 138 | } 139 | if err := i.Inventory.UpdateProductReview(ctx, params); err != nil { 140 | return nil, grpcAPIError(err) 141 | } 142 | return &apipb.UpdateProductReviewResponse{}, nil 143 | } 144 | 145 | func (i *InventoryGRPC) DeleteProductReview(ctx context.Context, req *apipb.DeleteProductReviewRequest) (*apipb.DeleteProductReviewResponse, error) { 146 | if err := i.Inventory.DeleteProductReview(ctx, req.GetId()); err != nil { 147 | return nil, grpcAPIError(err) 148 | } 149 | return &apipb.DeleteProductReviewResponse{}, nil 150 | } 151 | 152 | func (i *InventoryGRPC) GetProductReview(ctx context.Context, req *apipb.GetProductReviewRequest) (*apipb.GetProductReviewResponse, error) { 153 | review, err := i.Inventory.GetProductReview(ctx, req.GetId()) 154 | if err != nil { 155 | return nil, grpcAPIError(err) 156 | } 157 | if review == nil { 158 | return nil, status.Error(codes.NotFound, "review not found") 159 | } 160 | return apipb.GetProductReviewResponse_builder{ 161 | Id: proto.String(review.ID), 162 | ProductId: proto.String(review.ProductID), 163 | ReviewerId: proto.String(review.ReviewerID), 164 | Score: proto.Int32(int32(review.Score)), 165 | Title: proto.String(review.Title), 166 | Description: proto.String(review.Description), 167 | CreatedAt: proto.String(review.CreatedAt.String()), 168 | ModifiedAt: proto.String(review.ModifiedAt.String()), 169 | }.Build(), nil 170 | } 171 | 172 | // grpcAPIError wraps an error with gRPC API codes, when possible. 173 | func grpcAPIError(err error) error { 174 | switch { 175 | case err == context.DeadlineExceeded: 176 | return status.Error(codes.DeadlineExceeded, err.Error()) 177 | case err == context.Canceled: 178 | return status.Error(codes.Canceled, err.Error()) 179 | case errors.As(err, &inventory.ValidationError{}): 180 | return status.Error(codes.InvalidArgument, err.Error()) 181 | default: 182 | return err 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /internal/api/http.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log/slog" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/henvic/pgxtutorial/internal/inventory" 11 | "github.com/henvic/pgxtutorial/internal/telemetry" 12 | ) 13 | 14 | // NewHTTPServer creates an HTTP server for the API. 15 | func NewHTTPServer(i *inventory.Service, tel telemetry.Provider) http.Handler { 16 | s := &HTTPServer{ 17 | inventory: i, 18 | tel: tel, 19 | } 20 | mux := http.NewServeMux() 21 | mux.HandleFunc("GET /product/", s.handleGetProduct) 22 | mux.HandleFunc("GET /review/", s.handleGetProductReview) 23 | return mux 24 | } 25 | 26 | // HTTPServer exposes inventory.Service via HTTP. 27 | type HTTPServer struct { 28 | inventory *inventory.Service 29 | tel telemetry.Provider 30 | } 31 | 32 | func (s *HTTPServer) handleGetProduct(w http.ResponseWriter, r *http.Request) { 33 | id := r.URL.Path[len("/product/"):] 34 | if id == "" || strings.ContainsRune(id, '/') { 35 | http.NotFound(w, r) 36 | return 37 | } 38 | review, err := s.inventory.GetProduct(r.Context(), id) 39 | switch { 40 | case err == context.Canceled, err == context.DeadlineExceeded: 41 | return 42 | case err != nil: 43 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 | s.tel.Logger().Error("internal server error getting product", 45 | slog.Any("code", http.StatusInternalServerError), 46 | slog.Any("error", err), 47 | ) 48 | case review == nil: 49 | http.Error(w, "Product not found", http.StatusNotFound) 50 | default: 51 | w.Header().Set("Content-Type", "application/json") 52 | enc := json.NewEncoder(w) 53 | enc.SetIndent("", "\t") 54 | if err := enc.Encode(review); err != nil { 55 | s.tel.Logger().Info("cannot json encode product request", 56 | slog.Any("error", err), 57 | ) 58 | } 59 | } 60 | } 61 | 62 | func (s *HTTPServer) handleGetProductReview(w http.ResponseWriter, r *http.Request) { 63 | id := r.URL.Path[len("/review/"):] 64 | if id == "" || strings.ContainsRune(id, '/') { 65 | http.NotFound(w, r) 66 | return 67 | } 68 | review, err := s.inventory.GetProductReview(r.Context(), id) 69 | switch { 70 | case err == context.Canceled, err == context.DeadlineExceeded: 71 | return 72 | case err != nil: 73 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 74 | s.tel.Logger().Error("internal server error getting review", 75 | slog.Any("code", http.StatusInternalServerError), 76 | slog.Any("error", err), 77 | ) 78 | case review == nil: 79 | http.Error(w, "Review not found", http.StatusNotFound) 80 | default: 81 | w.Header().Set("Content-Type", "application/json") 82 | enc := json.NewEncoder(w) 83 | enc.SetIndent("", "\t") 84 | if err := enc.Encode(review); err != nil { 85 | s.tel.Logger().Info("cannot json encode review request", 86 | slog.Any("error", err), 87 | ) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/apiv1/api.proto: -------------------------------------------------------------------------------- 1 | edition = "2023"; 2 | 3 | package api.v1; 4 | import "google/protobuf/go_features.proto"; 5 | 6 | option go_package = "github.com/henvic/pgxtutorial/internal/apiv1/apipb"; 7 | option features.(pb.go).api_level = API_OPAQUE; 8 | 9 | // Inventory gRPC API service. 10 | service Inventory { 11 | rpc SearchProducts (SearchProductsRequest) returns (SearchProductsResponse) {} 12 | rpc CreateProduct (CreateProductRequest) returns (CreateProductResponse) {} 13 | rpc UpdateProduct (UpdateProductRequest) returns (UpdateProductResponse) {} 14 | rpc DeleteProduct (DeleteProductRequest) returns (DeleteProductResponse) {} 15 | rpc GetProduct (GetProductRequest) returns (GetProductResponse) {} 16 | 17 | rpc CreateProductReview (CreateProductReviewRequest) returns (CreateProductReviewResponse) {} 18 | rpc UpdateProductReview (UpdateProductReviewRequest) returns (UpdateProductReviewResponse) {} 19 | rpc DeleteProductReview (DeleteProductReviewRequest) returns (DeleteProductReviewResponse) {} 20 | rpc GetProductReview (GetProductReviewRequest) returns (GetProductReviewResponse) {} 21 | } 22 | 23 | // SearchProductsRequest message. 24 | message SearchProductsRequest { 25 | string query_string = 1; 26 | int64 min_price = 2; 27 | int64 max_price = 3; 28 | int32 page = 4 [default = 1]; 29 | } 30 | 31 | // SearchProductsResponse message. 32 | message SearchProductsResponse { 33 | int32 total = 1; 34 | repeated Product items = 2; 35 | } 36 | 37 | // Product message. 38 | message Product { 39 | string id = 1; 40 | int64 price = 2; 41 | string name = 3; 42 | string description = 4; 43 | } 44 | 45 | // CreateProductRequest message. 46 | message CreateProductRequest { 47 | string id = 1; 48 | string name = 2; 49 | string description = 3; 50 | int64 price = 4; 51 | } 52 | 53 | // CreateProductResponse message. 54 | message CreateProductResponse {} 55 | 56 | // UpdateProductRequest message. 57 | message UpdateProductRequest { 58 | string id = 1; 59 | string name = 2; 60 | string description = 3; 61 | int64 price = 4; 62 | } 63 | 64 | // UpdateProductResponse message. 65 | message UpdateProductResponse {} 66 | 67 | // DeleteProductRequest message. 68 | message DeleteProductRequest { 69 | string id = 1; 70 | } 71 | 72 | // DeleteProductResponse message. 73 | message DeleteProductResponse {} 74 | 75 | // GetProductRequest message. 76 | message GetProductRequest { 77 | string id = 1; 78 | } 79 | 80 | // GetProductResponse message. 81 | message GetProductResponse { 82 | string id = 1; 83 | int64 price = 2; 84 | string name = 3; 85 | string description = 4; 86 | string created_at = 5; 87 | string modified_at = 6; 88 | } 89 | 90 | // CreateProductReviewRequest message. 91 | message CreateProductReviewRequest { 92 | string product_id = 2; 93 | string reviewer_id = 3; 94 | int32 score = 4; 95 | string title = 5; 96 | string description = 6; 97 | } 98 | 99 | // CreateProductReviewResponse message. 100 | message CreateProductReviewResponse { 101 | string id = 1; 102 | } 103 | 104 | // UpdateProductReviewRequest message. 105 | message UpdateProductReviewRequest { 106 | string id = 1; 107 | int32 score = 4; 108 | string title = 5; 109 | string description = 6; 110 | } 111 | 112 | // UpdateProductReviewResponse message. 113 | message UpdateProductReviewResponse {} 114 | 115 | // DeleteProductReviewRequest message. 116 | message DeleteProductReviewRequest { 117 | string id = 1; 118 | } 119 | 120 | // DeleteProductReviewResponse message. 121 | message DeleteProductReviewResponse {} 122 | 123 | // GetProductReviewRequest message. 124 | message GetProductReviewRequest { 125 | string id = 1; 126 | } 127 | 128 | // GetProductReviewResponse message. 129 | message GetProductReviewResponse { 130 | string id = 1; 131 | string product_id = 2; 132 | string reviewer_id = 3; 133 | int32 score = 4; 134 | string title = 5; 135 | string description = 6; 136 | string created_at = 7; 137 | string modified_at = 8; 138 | } 139 | -------------------------------------------------------------------------------- /internal/apiv1/apipb/api_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v5.29.3 5 | // source: api.proto 6 | 7 | package apipb 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | Inventory_SearchProducts_FullMethodName = "/api.v1.Inventory/SearchProducts" 23 | Inventory_CreateProduct_FullMethodName = "/api.v1.Inventory/CreateProduct" 24 | Inventory_UpdateProduct_FullMethodName = "/api.v1.Inventory/UpdateProduct" 25 | Inventory_DeleteProduct_FullMethodName = "/api.v1.Inventory/DeleteProduct" 26 | Inventory_GetProduct_FullMethodName = "/api.v1.Inventory/GetProduct" 27 | Inventory_CreateProductReview_FullMethodName = "/api.v1.Inventory/CreateProductReview" 28 | Inventory_UpdateProductReview_FullMethodName = "/api.v1.Inventory/UpdateProductReview" 29 | Inventory_DeleteProductReview_FullMethodName = "/api.v1.Inventory/DeleteProductReview" 30 | Inventory_GetProductReview_FullMethodName = "/api.v1.Inventory/GetProductReview" 31 | ) 32 | 33 | // InventoryClient is the client API for Inventory service. 34 | // 35 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 36 | // 37 | // Inventory gRPC API service. 38 | type InventoryClient interface { 39 | SearchProducts(ctx context.Context, in *SearchProductsRequest, opts ...grpc.CallOption) (*SearchProductsResponse, error) 40 | CreateProduct(ctx context.Context, in *CreateProductRequest, opts ...grpc.CallOption) (*CreateProductResponse, error) 41 | UpdateProduct(ctx context.Context, in *UpdateProductRequest, opts ...grpc.CallOption) (*UpdateProductResponse, error) 42 | DeleteProduct(ctx context.Context, in *DeleteProductRequest, opts ...grpc.CallOption) (*DeleteProductResponse, error) 43 | GetProduct(ctx context.Context, in *GetProductRequest, opts ...grpc.CallOption) (*GetProductResponse, error) 44 | CreateProductReview(ctx context.Context, in *CreateProductReviewRequest, opts ...grpc.CallOption) (*CreateProductReviewResponse, error) 45 | UpdateProductReview(ctx context.Context, in *UpdateProductReviewRequest, opts ...grpc.CallOption) (*UpdateProductReviewResponse, error) 46 | DeleteProductReview(ctx context.Context, in *DeleteProductReviewRequest, opts ...grpc.CallOption) (*DeleteProductReviewResponse, error) 47 | GetProductReview(ctx context.Context, in *GetProductReviewRequest, opts ...grpc.CallOption) (*GetProductReviewResponse, error) 48 | } 49 | 50 | type inventoryClient struct { 51 | cc grpc.ClientConnInterface 52 | } 53 | 54 | func NewInventoryClient(cc grpc.ClientConnInterface) InventoryClient { 55 | return &inventoryClient{cc} 56 | } 57 | 58 | func (c *inventoryClient) SearchProducts(ctx context.Context, in *SearchProductsRequest, opts ...grpc.CallOption) (*SearchProductsResponse, error) { 59 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 60 | out := new(SearchProductsResponse) 61 | err := c.cc.Invoke(ctx, Inventory_SearchProducts_FullMethodName, in, out, cOpts...) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return out, nil 66 | } 67 | 68 | func (c *inventoryClient) CreateProduct(ctx context.Context, in *CreateProductRequest, opts ...grpc.CallOption) (*CreateProductResponse, error) { 69 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 70 | out := new(CreateProductResponse) 71 | err := c.cc.Invoke(ctx, Inventory_CreateProduct_FullMethodName, in, out, cOpts...) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return out, nil 76 | } 77 | 78 | func (c *inventoryClient) UpdateProduct(ctx context.Context, in *UpdateProductRequest, opts ...grpc.CallOption) (*UpdateProductResponse, error) { 79 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 80 | out := new(UpdateProductResponse) 81 | err := c.cc.Invoke(ctx, Inventory_UpdateProduct_FullMethodName, in, out, cOpts...) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return out, nil 86 | } 87 | 88 | func (c *inventoryClient) DeleteProduct(ctx context.Context, in *DeleteProductRequest, opts ...grpc.CallOption) (*DeleteProductResponse, error) { 89 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 90 | out := new(DeleteProductResponse) 91 | err := c.cc.Invoke(ctx, Inventory_DeleteProduct_FullMethodName, in, out, cOpts...) 92 | if err != nil { 93 | return nil, err 94 | } 95 | return out, nil 96 | } 97 | 98 | func (c *inventoryClient) GetProduct(ctx context.Context, in *GetProductRequest, opts ...grpc.CallOption) (*GetProductResponse, error) { 99 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 100 | out := new(GetProductResponse) 101 | err := c.cc.Invoke(ctx, Inventory_GetProduct_FullMethodName, in, out, cOpts...) 102 | if err != nil { 103 | return nil, err 104 | } 105 | return out, nil 106 | } 107 | 108 | func (c *inventoryClient) CreateProductReview(ctx context.Context, in *CreateProductReviewRequest, opts ...grpc.CallOption) (*CreateProductReviewResponse, error) { 109 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 110 | out := new(CreateProductReviewResponse) 111 | err := c.cc.Invoke(ctx, Inventory_CreateProductReview_FullMethodName, in, out, cOpts...) 112 | if err != nil { 113 | return nil, err 114 | } 115 | return out, nil 116 | } 117 | 118 | func (c *inventoryClient) UpdateProductReview(ctx context.Context, in *UpdateProductReviewRequest, opts ...grpc.CallOption) (*UpdateProductReviewResponse, error) { 119 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 120 | out := new(UpdateProductReviewResponse) 121 | err := c.cc.Invoke(ctx, Inventory_UpdateProductReview_FullMethodName, in, out, cOpts...) 122 | if err != nil { 123 | return nil, err 124 | } 125 | return out, nil 126 | } 127 | 128 | func (c *inventoryClient) DeleteProductReview(ctx context.Context, in *DeleteProductReviewRequest, opts ...grpc.CallOption) (*DeleteProductReviewResponse, error) { 129 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 130 | out := new(DeleteProductReviewResponse) 131 | err := c.cc.Invoke(ctx, Inventory_DeleteProductReview_FullMethodName, in, out, cOpts...) 132 | if err != nil { 133 | return nil, err 134 | } 135 | return out, nil 136 | } 137 | 138 | func (c *inventoryClient) GetProductReview(ctx context.Context, in *GetProductReviewRequest, opts ...grpc.CallOption) (*GetProductReviewResponse, error) { 139 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 140 | out := new(GetProductReviewResponse) 141 | err := c.cc.Invoke(ctx, Inventory_GetProductReview_FullMethodName, in, out, cOpts...) 142 | if err != nil { 143 | return nil, err 144 | } 145 | return out, nil 146 | } 147 | 148 | // InventoryServer is the server API for Inventory service. 149 | // All implementations must embed UnimplementedInventoryServer 150 | // for forward compatibility. 151 | // 152 | // Inventory gRPC API service. 153 | type InventoryServer interface { 154 | SearchProducts(context.Context, *SearchProductsRequest) (*SearchProductsResponse, error) 155 | CreateProduct(context.Context, *CreateProductRequest) (*CreateProductResponse, error) 156 | UpdateProduct(context.Context, *UpdateProductRequest) (*UpdateProductResponse, error) 157 | DeleteProduct(context.Context, *DeleteProductRequest) (*DeleteProductResponse, error) 158 | GetProduct(context.Context, *GetProductRequest) (*GetProductResponse, error) 159 | CreateProductReview(context.Context, *CreateProductReviewRequest) (*CreateProductReviewResponse, error) 160 | UpdateProductReview(context.Context, *UpdateProductReviewRequest) (*UpdateProductReviewResponse, error) 161 | DeleteProductReview(context.Context, *DeleteProductReviewRequest) (*DeleteProductReviewResponse, error) 162 | GetProductReview(context.Context, *GetProductReviewRequest) (*GetProductReviewResponse, error) 163 | mustEmbedUnimplementedInventoryServer() 164 | } 165 | 166 | // UnimplementedInventoryServer must be embedded to have 167 | // forward compatible implementations. 168 | // 169 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 170 | // pointer dereference when methods are called. 171 | type UnimplementedInventoryServer struct{} 172 | 173 | func (UnimplementedInventoryServer) SearchProducts(context.Context, *SearchProductsRequest) (*SearchProductsResponse, error) { 174 | return nil, status.Errorf(codes.Unimplemented, "method SearchProducts not implemented") 175 | } 176 | func (UnimplementedInventoryServer) CreateProduct(context.Context, *CreateProductRequest) (*CreateProductResponse, error) { 177 | return nil, status.Errorf(codes.Unimplemented, "method CreateProduct not implemented") 178 | } 179 | func (UnimplementedInventoryServer) UpdateProduct(context.Context, *UpdateProductRequest) (*UpdateProductResponse, error) { 180 | return nil, status.Errorf(codes.Unimplemented, "method UpdateProduct not implemented") 181 | } 182 | func (UnimplementedInventoryServer) DeleteProduct(context.Context, *DeleteProductRequest) (*DeleteProductResponse, error) { 183 | return nil, status.Errorf(codes.Unimplemented, "method DeleteProduct not implemented") 184 | } 185 | func (UnimplementedInventoryServer) GetProduct(context.Context, *GetProductRequest) (*GetProductResponse, error) { 186 | return nil, status.Errorf(codes.Unimplemented, "method GetProduct not implemented") 187 | } 188 | func (UnimplementedInventoryServer) CreateProductReview(context.Context, *CreateProductReviewRequest) (*CreateProductReviewResponse, error) { 189 | return nil, status.Errorf(codes.Unimplemented, "method CreateProductReview not implemented") 190 | } 191 | func (UnimplementedInventoryServer) UpdateProductReview(context.Context, *UpdateProductReviewRequest) (*UpdateProductReviewResponse, error) { 192 | return nil, status.Errorf(codes.Unimplemented, "method UpdateProductReview not implemented") 193 | } 194 | func (UnimplementedInventoryServer) DeleteProductReview(context.Context, *DeleteProductReviewRequest) (*DeleteProductReviewResponse, error) { 195 | return nil, status.Errorf(codes.Unimplemented, "method DeleteProductReview not implemented") 196 | } 197 | func (UnimplementedInventoryServer) GetProductReview(context.Context, *GetProductReviewRequest) (*GetProductReviewResponse, error) { 198 | return nil, status.Errorf(codes.Unimplemented, "method GetProductReview not implemented") 199 | } 200 | func (UnimplementedInventoryServer) mustEmbedUnimplementedInventoryServer() {} 201 | func (UnimplementedInventoryServer) testEmbeddedByValue() {} 202 | 203 | // UnsafeInventoryServer may be embedded to opt out of forward compatibility for this service. 204 | // Use of this interface is not recommended, as added methods to InventoryServer will 205 | // result in compilation errors. 206 | type UnsafeInventoryServer interface { 207 | mustEmbedUnimplementedInventoryServer() 208 | } 209 | 210 | func RegisterInventoryServer(s grpc.ServiceRegistrar, srv InventoryServer) { 211 | // If the following call pancis, it indicates UnimplementedInventoryServer was 212 | // embedded by pointer and is nil. This will cause panics if an 213 | // unimplemented method is ever invoked, so we test this at initialization 214 | // time to prevent it from happening at runtime later due to I/O. 215 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 216 | t.testEmbeddedByValue() 217 | } 218 | s.RegisterService(&Inventory_ServiceDesc, srv) 219 | } 220 | 221 | func _Inventory_SearchProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 222 | in := new(SearchProductsRequest) 223 | if err := dec(in); err != nil { 224 | return nil, err 225 | } 226 | if interceptor == nil { 227 | return srv.(InventoryServer).SearchProducts(ctx, in) 228 | } 229 | info := &grpc.UnaryServerInfo{ 230 | Server: srv, 231 | FullMethod: Inventory_SearchProducts_FullMethodName, 232 | } 233 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 234 | return srv.(InventoryServer).SearchProducts(ctx, req.(*SearchProductsRequest)) 235 | } 236 | return interceptor(ctx, in, info, handler) 237 | } 238 | 239 | func _Inventory_CreateProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 240 | in := new(CreateProductRequest) 241 | if err := dec(in); err != nil { 242 | return nil, err 243 | } 244 | if interceptor == nil { 245 | return srv.(InventoryServer).CreateProduct(ctx, in) 246 | } 247 | info := &grpc.UnaryServerInfo{ 248 | Server: srv, 249 | FullMethod: Inventory_CreateProduct_FullMethodName, 250 | } 251 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 252 | return srv.(InventoryServer).CreateProduct(ctx, req.(*CreateProductRequest)) 253 | } 254 | return interceptor(ctx, in, info, handler) 255 | } 256 | 257 | func _Inventory_UpdateProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 258 | in := new(UpdateProductRequest) 259 | if err := dec(in); err != nil { 260 | return nil, err 261 | } 262 | if interceptor == nil { 263 | return srv.(InventoryServer).UpdateProduct(ctx, in) 264 | } 265 | info := &grpc.UnaryServerInfo{ 266 | Server: srv, 267 | FullMethod: Inventory_UpdateProduct_FullMethodName, 268 | } 269 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 270 | return srv.(InventoryServer).UpdateProduct(ctx, req.(*UpdateProductRequest)) 271 | } 272 | return interceptor(ctx, in, info, handler) 273 | } 274 | 275 | func _Inventory_DeleteProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 276 | in := new(DeleteProductRequest) 277 | if err := dec(in); err != nil { 278 | return nil, err 279 | } 280 | if interceptor == nil { 281 | return srv.(InventoryServer).DeleteProduct(ctx, in) 282 | } 283 | info := &grpc.UnaryServerInfo{ 284 | Server: srv, 285 | FullMethod: Inventory_DeleteProduct_FullMethodName, 286 | } 287 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 288 | return srv.(InventoryServer).DeleteProduct(ctx, req.(*DeleteProductRequest)) 289 | } 290 | return interceptor(ctx, in, info, handler) 291 | } 292 | 293 | func _Inventory_GetProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 294 | in := new(GetProductRequest) 295 | if err := dec(in); err != nil { 296 | return nil, err 297 | } 298 | if interceptor == nil { 299 | return srv.(InventoryServer).GetProduct(ctx, in) 300 | } 301 | info := &grpc.UnaryServerInfo{ 302 | Server: srv, 303 | FullMethod: Inventory_GetProduct_FullMethodName, 304 | } 305 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 306 | return srv.(InventoryServer).GetProduct(ctx, req.(*GetProductRequest)) 307 | } 308 | return interceptor(ctx, in, info, handler) 309 | } 310 | 311 | func _Inventory_CreateProductReview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 312 | in := new(CreateProductReviewRequest) 313 | if err := dec(in); err != nil { 314 | return nil, err 315 | } 316 | if interceptor == nil { 317 | return srv.(InventoryServer).CreateProductReview(ctx, in) 318 | } 319 | info := &grpc.UnaryServerInfo{ 320 | Server: srv, 321 | FullMethod: Inventory_CreateProductReview_FullMethodName, 322 | } 323 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 324 | return srv.(InventoryServer).CreateProductReview(ctx, req.(*CreateProductReviewRequest)) 325 | } 326 | return interceptor(ctx, in, info, handler) 327 | } 328 | 329 | func _Inventory_UpdateProductReview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 330 | in := new(UpdateProductReviewRequest) 331 | if err := dec(in); err != nil { 332 | return nil, err 333 | } 334 | if interceptor == nil { 335 | return srv.(InventoryServer).UpdateProductReview(ctx, in) 336 | } 337 | info := &grpc.UnaryServerInfo{ 338 | Server: srv, 339 | FullMethod: Inventory_UpdateProductReview_FullMethodName, 340 | } 341 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 342 | return srv.(InventoryServer).UpdateProductReview(ctx, req.(*UpdateProductReviewRequest)) 343 | } 344 | return interceptor(ctx, in, info, handler) 345 | } 346 | 347 | func _Inventory_DeleteProductReview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 348 | in := new(DeleteProductReviewRequest) 349 | if err := dec(in); err != nil { 350 | return nil, err 351 | } 352 | if interceptor == nil { 353 | return srv.(InventoryServer).DeleteProductReview(ctx, in) 354 | } 355 | info := &grpc.UnaryServerInfo{ 356 | Server: srv, 357 | FullMethod: Inventory_DeleteProductReview_FullMethodName, 358 | } 359 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 360 | return srv.(InventoryServer).DeleteProductReview(ctx, req.(*DeleteProductReviewRequest)) 361 | } 362 | return interceptor(ctx, in, info, handler) 363 | } 364 | 365 | func _Inventory_GetProductReview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 366 | in := new(GetProductReviewRequest) 367 | if err := dec(in); err != nil { 368 | return nil, err 369 | } 370 | if interceptor == nil { 371 | return srv.(InventoryServer).GetProductReview(ctx, in) 372 | } 373 | info := &grpc.UnaryServerInfo{ 374 | Server: srv, 375 | FullMethod: Inventory_GetProductReview_FullMethodName, 376 | } 377 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 378 | return srv.(InventoryServer).GetProductReview(ctx, req.(*GetProductReviewRequest)) 379 | } 380 | return interceptor(ctx, in, info, handler) 381 | } 382 | 383 | // Inventory_ServiceDesc is the grpc.ServiceDesc for Inventory service. 384 | // It's only intended for direct use with grpc.RegisterService, 385 | // and not to be introspected or modified (even as a copy) 386 | var Inventory_ServiceDesc = grpc.ServiceDesc{ 387 | ServiceName: "api.v1.Inventory", 388 | HandlerType: (*InventoryServer)(nil), 389 | Methods: []grpc.MethodDesc{ 390 | { 391 | MethodName: "SearchProducts", 392 | Handler: _Inventory_SearchProducts_Handler, 393 | }, 394 | { 395 | MethodName: "CreateProduct", 396 | Handler: _Inventory_CreateProduct_Handler, 397 | }, 398 | { 399 | MethodName: "UpdateProduct", 400 | Handler: _Inventory_UpdateProduct_Handler, 401 | }, 402 | { 403 | MethodName: "DeleteProduct", 404 | Handler: _Inventory_DeleteProduct_Handler, 405 | }, 406 | { 407 | MethodName: "GetProduct", 408 | Handler: _Inventory_GetProduct_Handler, 409 | }, 410 | { 411 | MethodName: "CreateProductReview", 412 | Handler: _Inventory_CreateProductReview_Handler, 413 | }, 414 | { 415 | MethodName: "UpdateProductReview", 416 | Handler: _Inventory_UpdateProductReview_Handler, 417 | }, 418 | { 419 | MethodName: "DeleteProductReview", 420 | Handler: _Inventory_DeleteProductReview_Handler, 421 | }, 422 | { 423 | MethodName: "GetProductReview", 424 | Handler: _Inventory_GetProductReview_Handler, 425 | }, 426 | }, 427 | Streams: []grpc.StreamDesc{}, 428 | Metadata: "api.proto", 429 | } 430 | -------------------------------------------------------------------------------- /internal/apiv1/gen.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | // Generate Protobuf and gRPC code: 4 | //go:generate protoc --go_out=apipb --go_opt=paths=source_relative --go-grpc_out=./apipb --go-grpc_opt=paths=source_relative api.proto 5 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | 10 | "github.com/exaring/otelpgx" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | "github.com/jackc/pgx/v5/pgxpool" 13 | "github.com/jackc/pgx/v5/tracelog" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | // NewPGXPool is a PostgreSQL connection pool for pgx. 18 | // 19 | // Usage: 20 | // pgPool := database.NewPGXPool(context.Background(), "", &PGXStdLogger{Logger: slog.Default()}, tracelog.LogLevelInfo, tracer) 21 | // defer pgPool.Close() // Close any remaining connections before shutting down your application. 22 | // 23 | // Instead of passing a configuration explicitly with a connString, 24 | // you might use PG environment variables such as the following to configure the database: 25 | // PGDATABASE, PGHOST, PGPORT, PGUSER, PGPASSWORD, PGCONNECT_TIMEOUT, etc. 26 | // Reference: https://www.postgresql.org/docs/current/libpq-envars.html 27 | func NewPGXPool(ctx context.Context, connString string, logger tracelog.Logger, logLevel tracelog.LogLevel, tracer trace.TracerProvider) (*pgxpool.Pool, error) { 28 | conf, err := pgxpool.ParseConfig(connString) // Using environment variables instead of a connection string. 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | // Use github.com/exaring/otelpgx if a tracer is defined. 34 | // Otherwise, use github.com/jackc/pgx/v5/tracelog with the logger. 35 | if tracer != nil { 36 | conf.ConnConfig.Tracer = otelpgx.NewTracer(otelpgx.WithTracerProvider(tracer)) 37 | } else { 38 | conf.ConnConfig.Tracer = &tracelog.TraceLog{ 39 | Logger: logger, 40 | LogLevel: logLevel, 41 | } 42 | } 43 | 44 | // pgxpool default max number of connections is the number of CPUs on your machine returned by runtime.NumCPU(). 45 | // This number is very conservative, and you might be able to improve performance for highly concurrent applications 46 | // by increasing it. 47 | // conf.MaxConns = runtime.NumCPU() * 5 48 | pool, err := pgxpool.NewWithConfig(ctx, conf) 49 | if err != nil { 50 | return nil, fmt.Errorf("pgx connection error: %w", err) 51 | } 52 | return pool, nil 53 | } 54 | 55 | // LogLevelFromEnv returns the tracelog.LogLevel from the environment variable PGX_LOG_LEVEL. 56 | // By default this is info (tracelog.LogLevelInfo), which is good for development. 57 | // For deployments, something like tracelog.LogLevelWarn is better choice. 58 | func LogLevelFromEnv() (tracelog.LogLevel, error) { 59 | if level := os.Getenv("PGX_LOG_LEVEL"); level != "" { 60 | l, err := tracelog.LogLevelFromString(level) 61 | if err != nil { 62 | return tracelog.LogLevelDebug, fmt.Errorf("pgx configuration: %w", err) 63 | } 64 | return l, nil 65 | } 66 | return tracelog.LogLevelInfo, nil 67 | } 68 | 69 | // PGXStdLogger prints pgx logs to the standard logger. 70 | // os.Stderr by default. 71 | type PGXStdLogger struct { 72 | Logger *slog.Logger 73 | } 74 | 75 | func (l *PGXStdLogger) Log(ctx context.Context, level tracelog.LogLevel, msg string, data map[string]any) { 76 | attrs := make([]slog.Attr, 0, len(data)+1) 77 | attrs = append(attrs, slog.String("pgx_level", level.String())) 78 | for k, v := range data { 79 | attrs = append(attrs, slog.Any(k, v)) 80 | } 81 | l.Logger.LogAttrs(ctx, slogLevel(level), msg, attrs...) 82 | } 83 | 84 | // slogLevel translates pgx log level to slog log level. 85 | func slogLevel(level tracelog.LogLevel) slog.Level { 86 | switch level { 87 | case tracelog.LogLevelTrace, tracelog.LogLevelDebug: 88 | return slog.LevelDebug 89 | case tracelog.LogLevelInfo: 90 | return slog.LevelInfo 91 | case tracelog.LogLevelWarn: 92 | return slog.LevelWarn 93 | default: 94 | // If tracelog.LogLevelError, tracelog.LogLevelNone, or any other unknown level, use slog.LevelError. 95 | return slog.LevelError 96 | } 97 | } 98 | 99 | // PgErrors returns a multi-line error printing more information from *pgconn.PgError to make debugging faster. 100 | func PgErrors(err error) error { 101 | var pgErr *pgconn.PgError 102 | if !errors.As(err, &pgErr) { 103 | return err 104 | } 105 | return fmt.Errorf(`%w 106 | Code: %v 107 | Detail: %v 108 | Hint: %v 109 | Position: %v 110 | InternalPosition: %v 111 | InternalQuery: %v 112 | Where: %v 113 | SchemaName: %v 114 | TableName: %v 115 | ColumnName: %v 116 | DataTypeName: %v 117 | ConstraintName: %v 118 | File: %v:%v 119 | Routine: %v`, 120 | err, 121 | pgErr.Code, 122 | pgErr.Detail, 123 | pgErr.Hint, 124 | pgErr.Position, 125 | pgErr.InternalPosition, 126 | pgErr.InternalQuery, 127 | pgErr.Where, 128 | pgErr.SchemaName, 129 | pgErr.TableName, 130 | pgErr.ColumnName, 131 | pgErr.DataTypeName, 132 | pgErr.ConstraintName, 133 | pgErr.File, pgErr.Line, 134 | pgErr.Routine) 135 | } 136 | -------------------------------------------------------------------------------- /internal/database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "log/slog" 7 | "os" 8 | "testing" 9 | 10 | "github.com/jackc/pgx/v5/log/testingadapter" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | "github.com/jackc/pgx/v5/pgxpool" 13 | "github.com/jackc/pgx/v5/tracelog" 14 | ) 15 | 16 | func TestMain(m *testing.M) { 17 | if os.Getenv("INTEGRATION_TESTDB") != "true" { 18 | log.Printf("Skipping tests that require database connection") 19 | return 20 | } 21 | os.Exit(m.Run()) 22 | } 23 | 24 | func TestNewPGXPool(t *testing.T) { 25 | t.Parallel() 26 | 27 | pool, err := NewPGXPool(t.Context(), "", &PGXStdLogger{ 28 | Logger: slog.Default(), 29 | }, tracelog.LogLevelInfo, nil) 30 | if err != nil { 31 | t.Fatalf("NewPGXPool() error: %v", err) 32 | } 33 | defer pool.Close() 34 | 35 | // Check reachability. 36 | if _, err = pool.Exec(t.Context(), `SELECT 1`); err != nil { 37 | t.Errorf("pool.Exec() error: %v", err) 38 | } 39 | } 40 | 41 | func TestNewPGXPoolErrors(t *testing.T) { 42 | t.Parallel() 43 | type args struct { 44 | ctx context.Context 45 | connString string 46 | logger tracelog.Logger 47 | logLevel tracelog.LogLevel 48 | } 49 | tests := []struct { 50 | name string 51 | args args 52 | want *pgxpool.Pool 53 | wantErr bool 54 | }{ 55 | { 56 | name: "invalid_connection_string", 57 | args: args{ 58 | ctx: t.Context(), 59 | connString: "http://localhost", 60 | logger: testingadapter.NewLogger(t), 61 | logLevel: tracelog.LogLevelInfo, 62 | }, 63 | want: nil, 64 | wantErr: true, 65 | }, 66 | } 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | got, err := NewPGXPool(tt.args.ctx, tt.args.connString, tt.args.logger, tt.args.logLevel, nil) 70 | if (err != nil) != tt.wantErr { 71 | t.Errorf("NewPGXPool() error = %v, wantErr %v", err, tt.wantErr) 72 | return 73 | } 74 | if tt.wantErr && got != nil { 75 | t.Errorf("NewPGXPool() = %v, want nil", got) 76 | } 77 | }) 78 | } 79 | } 80 | 81 | func TestLogLevelFromEnv(t *testing.T) { 82 | tests := []struct { 83 | name string 84 | env string 85 | want tracelog.LogLevel 86 | wantErr string 87 | }{ 88 | { 89 | name: "default", 90 | want: tracelog.LogLevelInfo, 91 | }, 92 | { 93 | name: "warn", 94 | env: "warn", 95 | want: tracelog.LogLevelWarn, 96 | }, 97 | { 98 | name: "error", 99 | env: "bad", 100 | want: tracelog.LogLevelDebug, 101 | wantErr: "pgx configuration: invalid log level", 102 | }, 103 | } 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | if tt.env != "" { 107 | t.Setenv("PGX_LOG_LEVEL", tt.env) 108 | } 109 | got, err := LogLevelFromEnv() 110 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 111 | t.Errorf("LogLevelFromEnv() error = %v, wantErr %v", err, tt.wantErr) 112 | return 113 | } 114 | if got != tt.want { 115 | t.Errorf("LogLevelFromEnv() = %v, want %v", got, tt.want) 116 | } 117 | }) 118 | } 119 | } 120 | 121 | func TestPgErrors(t *testing.T) { 122 | t.Parallel() 123 | tests := []struct { 124 | name string 125 | err error 126 | wantErr string 127 | }{ 128 | { 129 | name: "nil", 130 | wantErr: "", 131 | }, 132 | { 133 | name: "other", 134 | err: context.Canceled, 135 | wantErr: "context canceled", 136 | }, 137 | { 138 | name: "essential", 139 | err: &pgconn.PgError{ 140 | Severity: "ERROR", 141 | Message: "msg", 142 | Code: "007", 143 | Detail: "detail", 144 | Hint: "hint", 145 | Position: 2, 146 | InternalPosition: 4, 147 | InternalQuery: "q", 148 | Where: "w", 149 | SchemaName: "public", 150 | TableName: "names", 151 | ColumnName: "field", 152 | DataTypeName: "jsonb", 153 | ConstraintName: "foo_id_fkey", 154 | File: "main.c", 155 | Line: 14, 156 | Routine: "a", 157 | }, 158 | wantErr: `ERROR: msg (SQLSTATE 007) 159 | Code: 007 160 | Detail: detail 161 | Hint: hint 162 | Position: 2 163 | InternalPosition: 4 164 | InternalQuery: q 165 | Where: w 166 | SchemaName: public 167 | TableName: names 168 | ColumnName: field 169 | DataTypeName: jsonb 170 | ConstraintName: foo_id_fkey 171 | File: main.c:14 172 | Routine: a`, 173 | }, 174 | } 175 | for _, tt := range tests { 176 | t.Run(tt.name, func(t *testing.T) { 177 | if err := PgErrors(tt.err); err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 178 | t.Errorf("PgErrors() error = %v, wantErr %v", err, tt.wantErr) 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /internal/database/interface.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgx/v5" 7 | "github.com/jackc/pgx/v5/pgconn" 8 | "github.com/jackc/pgx/v5/pgxpool" 9 | ) 10 | 11 | // PGX limited interface with high-level API for pgx methods safe to be used in high-level business logic packages. 12 | // It is satisfied by implementations *pgx.Conn and *pgxpool.Pool (and you should probably use the second one usually). 13 | // 14 | // Caveat: It doesn't expose a method to acquire a *pgx.Conn or handle notifications, 15 | // so it's not compatible with LISTEN/NOTIFY. 16 | // 17 | // Reference: https://pkg.go.dev/github.com/jackc/pgx/v5 18 | type PGX interface { 19 | // BeginTx starts a transaction with txOptions determining the transaction mode. Unlike database/sql, the context only 20 | // affects the begin command. i.e. there is no auto-rollback on context cancellation. 21 | BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) 22 | 23 | PGXQuerier 24 | } 25 | 26 | // PGXQuerier interface with methods used for everything, including transactions. 27 | type PGXQuerier interface { 28 | // Begin starts a transaction. Unlike database/sql, the context only affects the begin command. i.e. there is no 29 | // auto-rollback on context cancellation. 30 | Begin(ctx context.Context) (pgx.Tx, error) 31 | 32 | // CopyFrom uses the PostgreSQL copy protocol to perform bulk data insertion. 33 | // It returns the number of rows copied and an error. 34 | // 35 | // CopyFrom requires all values use the binary format. Almost all types 36 | // implemented by pgx use the binary format by default. Types implementing 37 | // Encoder can only be used if they encode to the binary format. 38 | CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) 39 | 40 | // Exec executes sql. sql can be either a prepared statement name or an SQL string. arguments should be referenced 41 | // positionally from the sql string as $1, $2, etc. 42 | Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) 43 | 44 | // Query executes sql with args. If there is an error the returned Rows will be returned in an error state. So it is 45 | // allowed to ignore the error returned from Query and handle it in Rows. 46 | // 47 | // For extra control over how the query is executed, the types QuerySimpleProtocol, QueryResultFormats, and 48 | // QueryResultFormatsByOID may be used as the first args to control exactly how the query is executed. This is rarely 49 | // needed. See the documentation for those types for details. 50 | Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) 51 | 52 | // QueryRow is a convenience wrapper over Query. Any error that occurs while 53 | // querying is deferred until calling Scan on the returned Row. That Row will 54 | // error with ErrNoRows if no rows are returned. 55 | QueryRow(ctx context.Context, sql string, args ...any) pgx.Row 56 | 57 | // SendBatch sends all queued queries to the server at once. All queries are run in an implicit transaction unless 58 | // explicit transaction control statements are executed. The returned BatchResults must be closed before the connection 59 | // is used again. 60 | SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults 61 | } 62 | 63 | // Validate if the PGX interface was derived from *pgx.Conn and *pgxpool.Pool correctly. 64 | var ( 65 | _ PGX = (*pgx.Conn)(nil) 66 | _ PGX = (*pgxpool.Pool)(nil) 67 | ) 68 | -------------------------------------------------------------------------------- /internal/inventory/helper_test.go: -------------------------------------------------------------------------------- 1 | package inventory_test 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/henvic/pgtools/sqltest" 11 | "github.com/henvic/pgxtutorial/internal/inventory" 12 | "github.com/henvic/pgxtutorial/internal/postgres" 13 | ) 14 | 15 | func createProducts(t testing.TB, s *inventory.Service, products []inventory.CreateProductParams) { 16 | for _, p := range products { 17 | if err := s.CreateProduct(t.Context(), p); err != nil { 18 | t.Errorf("Service.CreateProduct() error = %v", err) 19 | } 20 | } 21 | } 22 | 23 | func createProductReview(t testing.TB, s *inventory.Service, review inventory.CreateProductReviewParams) (id string) { 24 | id, err := s.CreateProductReview(t.Context(), review) 25 | if err != nil { 26 | t.Errorf("DB.CreateProductReview() error = %v", err) 27 | } 28 | return id 29 | } 30 | 31 | func canceledContext(ctx context.Context) context.Context { 32 | ctx, cancel := context.WithCancel(ctx) 33 | cancel() 34 | return ctx 35 | } 36 | 37 | func deadlineExceededContext(ctx context.Context) context.Context { 38 | ctx, cancel := context.WithTimeout(ctx, -time.Second) 39 | cancel() 40 | return ctx 41 | } 42 | 43 | // ptr returns a pointer to the given value. 44 | func ptr[T any](v T) *T { 45 | return &v 46 | } 47 | 48 | // serviceWithPostgres returns a new inventory.Service backed by a postgres.DB, if available. 49 | // Otherwise, it returns nil. 50 | func serviceWithPostgres(t *testing.T) *inventory.Service { 51 | t.Helper() 52 | var db *inventory.Service 53 | // Initialize migration and infrastructure for running tests that uses a real implementation of PostgreSQL 54 | // if the INTEGRATION_TESTDB environment variable is set to true. 55 | if os.Getenv("INTEGRATION_TESTDB") == "true" { 56 | migration := sqltest.New(t, sqltest.Options{ 57 | Force: true, 58 | TemporaryDatabasePrefix: "test_inventory_pkg", // Avoid a clash between database names of packages on parallel execution. 59 | Files: os.DirFS("../../migrations"), 60 | }) 61 | db = inventory.NewService(postgres.NewDB(migration.Setup(t.Context(), ""), slog.Default())) 62 | } 63 | return db 64 | } 65 | -------------------------------------------------------------------------------- /internal/inventory/inventory.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Product on the catalog. 9 | type Product struct { 10 | ID string 11 | Name string 12 | Description string 13 | Price int 14 | CreatedAt time.Time 15 | ModifiedAt time.Time 16 | } 17 | 18 | // CreateProductParams used by CreateProduct. 19 | type CreateProductParams struct { 20 | ID string 21 | Name string 22 | Description string 23 | Price int 24 | } 25 | 26 | func (p *CreateProductParams) validate() error { 27 | if p.ID == "" { 28 | return ValidationError{"missing product ID"} 29 | } 30 | if p.Name == "" { 31 | return ValidationError{"missing product name"} 32 | } 33 | if p.Description == "" { 34 | return ValidationError{"missing product description"} 35 | } 36 | if p.Price < 0 { 37 | return ValidationError{"price cannot be negative"} 38 | } 39 | return nil 40 | } 41 | 42 | // CreateProduct creates a new product. 43 | func (s *Service) CreateProduct(ctx context.Context, params CreateProductParams) (err error) { 44 | if err := params.validate(); err != nil { 45 | return err 46 | } 47 | return s.db.CreateProduct(ctx, params) 48 | } 49 | 50 | // UpdateProductParams used by UpdateProduct. 51 | type UpdateProductParams struct { 52 | ID string 53 | Name *string 54 | Description *string 55 | Price *int 56 | } 57 | 58 | func (p *UpdateProductParams) validate() error { 59 | if p.ID == "" { 60 | return ValidationError{"missing product ID"} 61 | } 62 | if p.Name == nil && p.Description == nil && p.Price == nil { 63 | return ValidationError{"no product arguments to update"} 64 | } 65 | if p.Name != nil && *p.Name == "" { 66 | return ValidationError{"missing product name"} 67 | } 68 | if p.Description != nil && *p.Description == "" { 69 | return ValidationError{"missing product description"} 70 | } 71 | if p.Price != nil && *p.Price < 0 { 72 | return ValidationError{"price cannot be negative"} 73 | } 74 | return nil 75 | } 76 | 77 | // UpdateProduct creates a new product. 78 | func (s *Service) UpdateProduct(ctx context.Context, params UpdateProductParams) (err error) { 79 | if err := params.validate(); err != nil { 80 | return err 81 | } 82 | return s.db.UpdateProduct(ctx, params) 83 | } 84 | 85 | // DeleteProduct deletes a product. 86 | func (s *Service) DeleteProduct(ctx context.Context, id string) (err error) { 87 | if id == "" { 88 | return ValidationError{"missing product ID"} 89 | } 90 | return s.db.DeleteProduct(ctx, id) 91 | } 92 | 93 | // GetProduct returns a product. 94 | func (s *Service) GetProduct(ctx context.Context, id string) (*Product, error) { 95 | if id == "" { 96 | return nil, ValidationError{"missing product ID"} 97 | } 98 | return s.db.GetProduct(ctx, id) 99 | } 100 | 101 | // SearchProductsParams used by SearchProducts. 102 | type SearchProductsParams struct { 103 | QueryString string 104 | MinPrice int 105 | MaxPrice int 106 | Pagination Pagination 107 | } 108 | 109 | func (p *SearchProductsParams) validate() error { 110 | if p.QueryString == "" { 111 | return ValidationError{"missing search string"} 112 | } 113 | if p.MinPrice < 0 { 114 | return ValidationError{"min price cannot be negative"} 115 | } 116 | if p.MaxPrice < 0 { 117 | return ValidationError{"max price cannot be negative"} 118 | } 119 | return p.Pagination.Validate() 120 | } 121 | 122 | // SearchProductsResponse from SearchProducts. 123 | type SearchProductsResponse struct { 124 | Items []*Product 125 | Total int32 126 | } 127 | 128 | // SearchProducts returns a list of products. 129 | func (s *Service) SearchProducts(ctx context.Context, params SearchProductsParams) (*SearchProductsResponse, error) { 130 | if err := params.validate(); err != nil { 131 | return nil, err 132 | } 133 | return s.db.SearchProducts(ctx, params) 134 | } 135 | -------------------------------------------------------------------------------- /internal/inventory/inventory_test.go: -------------------------------------------------------------------------------- 1 | package inventory_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/google/go-cmp/cmp/cmpopts" 13 | "github.com/henvic/pgxtutorial/internal/inventory" 14 | "go.uber.org/mock/gomock" 15 | ) 16 | 17 | func TestMain(m *testing.M) { 18 | if os.Getenv("INTEGRATION_TESTDB") != "true" { 19 | log.Printf("Skipping tests that require database connection") 20 | return 21 | } 22 | os.Exit(m.Run()) 23 | } 24 | 25 | func TestServiceCreateProduct(t *testing.T) { 26 | t.Parallel() 27 | var service = serviceWithPostgres(t) 28 | type args struct { 29 | ctx context.Context 30 | params inventory.CreateProductParams 31 | } 32 | tests := []struct { 33 | name string 34 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 35 | args args 36 | want *inventory.Product 37 | wantErr string 38 | }{ 39 | { 40 | name: "empty", 41 | args: args{ 42 | ctx: t.Context(), 43 | params: inventory.CreateProductParams{}, 44 | }, 45 | wantErr: "missing product ID", 46 | }, 47 | { 48 | name: "simple", 49 | args: args{ 50 | ctx: t.Context(), 51 | params: inventory.CreateProductParams{ 52 | ID: "simple", 53 | Name: "product name", 54 | Description: "product description", 55 | Price: 150, 56 | }, 57 | }, 58 | want: &inventory.Product{ 59 | ID: "simple", 60 | Name: "product name", 61 | Description: "product description", 62 | Price: 150, 63 | CreatedAt: time.Now(), 64 | ModifiedAt: time.Now(), 65 | }, 66 | wantErr: "", 67 | }, 68 | { 69 | name: "no_product_name", 70 | args: args{ 71 | ctx: t.Context(), 72 | params: inventory.CreateProductParams{ 73 | ID: "no_product_name", 74 | Name: "", 75 | Description: "product description", 76 | Price: 150, 77 | }, 78 | }, 79 | wantErr: "missing product name", 80 | }, 81 | { 82 | name: "no_product_description", 83 | args: args{ 84 | ctx: t.Context(), 85 | params: inventory.CreateProductParams{ 86 | ID: "no_product_description", 87 | Name: "product name", 88 | Price: 150, 89 | }, 90 | }, 91 | wantErr: "missing product description", 92 | }, 93 | { 94 | name: "negative_price", 95 | args: args{ 96 | ctx: t.Context(), 97 | params: inventory.CreateProductParams{ 98 | ID: "negative_price", 99 | Name: "product name", 100 | Description: "product description", 101 | Price: -5, 102 | }, 103 | }, 104 | wantErr: "price cannot be negative", 105 | }, 106 | { 107 | name: "canceled_ctx", 108 | args: args{ 109 | ctx: canceledContext(t.Context()), 110 | params: inventory.CreateProductParams{ 111 | ID: "simple", 112 | Name: "product name", 113 | Description: "product description", 114 | Price: 150, 115 | }, 116 | }, 117 | wantErr: "context canceled", 118 | }, 119 | { 120 | name: "database_error", 121 | mock: func(t testing.TB) *inventory.MockDB { 122 | ctrl := gomock.NewController(t) 123 | m := inventory.NewMockDB(ctrl) 124 | m.EXPECT().CreateProduct(gomock.Not(gomock.Nil()), 125 | inventory.CreateProductParams{ 126 | ID: "simple", 127 | Name: "product name", 128 | Description: "product description", 129 | Price: 150, 130 | }).Return(errors.New("unexpected error")) 131 | return m 132 | }, 133 | args: args{ 134 | ctx: t.Context(), 135 | params: inventory.CreateProductParams{ 136 | ID: "simple", 137 | Name: "product name", 138 | Description: "product description", 139 | Price: 150, 140 | }, 141 | }, 142 | wantErr: "unexpected error", 143 | }, 144 | } 145 | 146 | for _, tt := range tests { 147 | t.Run(tt.name, func(t *testing.T) { 148 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 149 | var s = service 150 | if tt.mock != nil { 151 | s = inventory.NewService(tt.mock(t)) 152 | } else if s == nil { 153 | t.Skip("required database not found, skipping test") 154 | } 155 | err := s.CreateProduct(tt.args.ctx, tt.args.params) 156 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 157 | t.Errorf("Service.CreateProduct() error = %v, wantErr %v", err, tt.wantErr) 158 | } 159 | if err != nil { 160 | return 161 | } 162 | // Only check integration / real implementation database for data. 163 | if tt.mock != nil { 164 | return 165 | } 166 | // Reusing GetProduct to check if the product was created successfully. 167 | got, err := s.GetProduct(tt.args.ctx, tt.args.params.ID) 168 | if err != nil { 169 | t.Errorf("Service.GetProduct() error = %v", err) 170 | } 171 | if !cmp.Equal(tt.want, got, cmpopts.EquateApproxTime(time.Minute)) { 172 | t.Errorf("value returned by Service.GetProduct() doesn't match: %v", cmp.Diff(tt.want, got)) 173 | } 174 | }) 175 | } 176 | } 177 | 178 | func TestServiceUpdateProduct(t *testing.T) { 179 | t.Parallel() 180 | var service = serviceWithPostgres(t) 181 | // Add some products that will be modified next: 182 | createProducts(t, service, []inventory.CreateProductParams{ 183 | { 184 | ID: "product", 185 | Name: "Original name", 186 | Description: "This is the original description", 187 | Price: 250, 188 | }, 189 | { 190 | ID: "another", 191 | Name: "Is your SQL UPDATE call correct?", 192 | Description: "Only the price of this one should be modified", 193 | Price: 99, 194 | }, 195 | }) 196 | 197 | type args struct { 198 | ctx context.Context 199 | params inventory.UpdateProductParams 200 | } 201 | tests := []struct { 202 | name string 203 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 204 | args args 205 | want *inventory.Product 206 | wantErr string 207 | }{ 208 | { 209 | name: "empty", 210 | args: args{ 211 | ctx: t.Context(), 212 | params: inventory.UpdateProductParams{}, 213 | }, 214 | wantErr: "missing product ID", 215 | }, 216 | { 217 | name: "no_product_name", 218 | args: args{ 219 | ctx: t.Context(), 220 | params: inventory.UpdateProductParams{ 221 | ID: "no_product_name", 222 | Name: ptr(""), 223 | Description: ptr("product description"), 224 | Price: ptr(150), 225 | }, 226 | }, 227 | wantErr: "missing product name", 228 | }, 229 | { 230 | name: "no_product_description", 231 | args: args{ 232 | ctx: t.Context(), 233 | params: inventory.UpdateProductParams{ 234 | ID: "no_product_description", 235 | Name: ptr("product name"), 236 | Description: ptr(""), 237 | Price: ptr(150), 238 | }, 239 | }, 240 | wantErr: "missing product description", 241 | }, 242 | { 243 | name: "negative_price", 244 | args: args{ 245 | ctx: t.Context(), 246 | params: inventory.UpdateProductParams{ 247 | ID: "negative_price", 248 | Name: ptr("product name"), 249 | Description: ptr("product description"), 250 | Price: ptr(-5), 251 | }, 252 | }, 253 | wantErr: "price cannot be negative", 254 | }, 255 | { 256 | name: "product_name_change", 257 | args: args{ 258 | ctx: t.Context(), 259 | params: inventory.UpdateProductParams{ 260 | ID: "product", 261 | Name: ptr("A new name"), 262 | }, 263 | }, 264 | want: &inventory.Product{ 265 | ID: "product", 266 | Name: "A new name", 267 | Description: "This is the original description", 268 | Price: 250, 269 | }, 270 | }, 271 | { 272 | name: "product_description_change", 273 | args: args{ 274 | ctx: t.Context(), 275 | params: inventory.UpdateProductParams{ 276 | ID: "product", 277 | Description: ptr("A new description"), 278 | }, 279 | }, 280 | want: &inventory.Product{ 281 | ID: "product", 282 | Name: "A new name", 283 | Description: "A new description", 284 | Price: 250, 285 | }, 286 | }, 287 | { 288 | name: "product_changes", 289 | args: args{ 290 | ctx: t.Context(), 291 | params: inventory.UpdateProductParams{ 292 | ID: "product", 293 | Name: ptr("Even another name"), 294 | Description: ptr("yet another description"), 295 | Price: ptr(400), 296 | }, 297 | }, 298 | want: &inventory.Product{ 299 | ID: "product", 300 | Name: "Even another name", 301 | Description: "yet another description", 302 | Price: 400, 303 | }, 304 | }, 305 | { 306 | name: "not_found", 307 | args: args{ 308 | ctx: t.Context(), 309 | params: inventory.UpdateProductParams{ 310 | ID: "World", 311 | Name: ptr("Earth"), 312 | }, 313 | }, 314 | wantErr: "product not found", 315 | }, 316 | { 317 | name: "update_product_check_violation", 318 | args: args{ 319 | ctx: t.Context(), 320 | params: inventory.UpdateProductParams{ 321 | ID: "product", 322 | Name: ptr(""), 323 | }, 324 | }, 325 | wantErr: "missing product name", 326 | }, 327 | { 328 | name: "canceled_ctx", 329 | args: args{ 330 | ctx: canceledContext(t.Context()), 331 | params: inventory.UpdateProductParams{ 332 | ID: "product", 333 | Name: ptr("Earth"), 334 | }, 335 | }, 336 | wantErr: "context canceled", 337 | }, 338 | { 339 | name: "deadline_exceeded_ctx", 340 | args: args{ 341 | ctx: deadlineExceededContext(t.Context()), 342 | params: inventory.UpdateProductParams{ 343 | ID: "product", 344 | Name: ptr("Earth"), 345 | }, 346 | }, 347 | wantErr: "context deadline exceeded", 348 | }, 349 | { 350 | name: "another_product_price_change", 351 | args: args{ 352 | ctx: t.Context(), 353 | params: inventory.UpdateProductParams{ 354 | ID: "another", 355 | Price: ptr(97), 356 | }, 357 | }, 358 | want: &inventory.Product{ 359 | ID: "another", 360 | Name: "Is your SQL UPDATE call correct?", 361 | Description: "Only the price of this one should be modified", 362 | Price: 97, 363 | }, 364 | }, 365 | { 366 | name: "no_changes", 367 | args: args{ 368 | ctx: t.Context(), 369 | params: inventory.UpdateProductParams{ 370 | ID: "no_changes", 371 | }, 372 | }, 373 | wantErr: "no product arguments to update", 374 | }, 375 | { 376 | name: "database_error", 377 | mock: func(t testing.TB) *inventory.MockDB { 378 | ctrl := gomock.NewController(t) 379 | m := inventory.NewMockDB(ctrl) 380 | m.EXPECT().UpdateProduct(gomock.Not(gomock.Nil()), 381 | inventory.UpdateProductParams{ 382 | ID: "simple", 383 | Name: ptr("product name"), 384 | Description: ptr("product description"), 385 | Price: ptr(150), 386 | }).Return(errors.New("unexpected error")) 387 | return m 388 | }, 389 | args: args{ 390 | ctx: t.Context(), 391 | params: inventory.UpdateProductParams{ 392 | ID: "simple", 393 | Name: ptr("product name"), 394 | Description: ptr("product description"), 395 | Price: ptr(150), 396 | }, 397 | }, 398 | wantErr: "unexpected error", 399 | }, 400 | } 401 | 402 | for _, tt := range tests { 403 | t.Run(tt.name, func(t *testing.T) { 404 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 405 | var s = service 406 | if tt.mock != nil { 407 | s = inventory.NewService(tt.mock(t)) 408 | } else if s == nil { 409 | t.Skip("required database not found, skipping test") 410 | } 411 | err := s.UpdateProduct(tt.args.ctx, tt.args.params) 412 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 413 | t.Errorf("Service.UpdateProduct() error = %v, wantErr %v", err, tt.wantErr) 414 | } 415 | if err != nil { 416 | return 417 | } 418 | got, err := s.GetProduct(tt.args.ctx, tt.args.params.ID) 419 | if err != nil { 420 | t.Errorf("Service.GetProduct() error = %v", err) 421 | } 422 | if got.CreatedAt.IsZero() { 423 | t.Error("Service.GetProduct() returned CreatedAt should not be zero") 424 | } 425 | if !got.CreatedAt.Before(got.ModifiedAt) { 426 | t.Error("Service.GetProduct() should return CreatedAt < ModifiedAt") 427 | } 428 | // Ignore or CreatedAt and ModifiedAt before comparing structs. 429 | if !cmp.Equal(tt.want, got, cmpopts.IgnoreFields(inventory.Product{}, "CreatedAt", "ModifiedAt")) { 430 | t.Errorf("value returned by DB.GetProduct() doesn't match: %v", cmp.Diff(tt.want, got)) 431 | } 432 | }) 433 | } 434 | } 435 | 436 | func TestServiceDeleteProduct(t *testing.T) { 437 | t.Parallel() 438 | var service = serviceWithPostgres(t) 439 | createProducts(t, service, []inventory.CreateProductParams{ 440 | { 441 | ID: "product", 442 | Name: "Product name", 443 | Description: "Product description", 444 | Price: 123, 445 | }, 446 | }) 447 | 448 | type args struct { 449 | ctx context.Context 450 | id string 451 | } 452 | tests := []struct { 453 | name string 454 | args args 455 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 456 | wantErr string 457 | }{ 458 | { 459 | name: "missing_product_id", 460 | args: args{ 461 | ctx: t.Context(), 462 | id: "", 463 | }, 464 | wantErr: "missing product ID", 465 | }, 466 | { 467 | name: "product", 468 | args: args{ 469 | ctx: t.Context(), 470 | id: "product", 471 | }, 472 | wantErr: "", 473 | }, 474 | // calling delete multiple times should not fail 475 | { 476 | name: "product_already_deleted", 477 | args: args{ 478 | ctx: t.Context(), 479 | id: "product", 480 | }, 481 | wantErr: "", 482 | }, 483 | // delete should be idempotent 484 | { 485 | name: "not_found", 486 | args: args{ 487 | ctx: t.Context(), 488 | id: "xyz", 489 | }, 490 | }, 491 | { 492 | name: "canceled_ctx", 493 | args: args{ 494 | ctx: canceledContext(t.Context()), 495 | id: "product", 496 | }, 497 | wantErr: "context canceled", 498 | }, 499 | { 500 | name: "deadline_exceeded_ctx", 501 | args: args{ 502 | ctx: deadlineExceededContext(t.Context()), 503 | id: "product", 504 | }, 505 | wantErr: "context deadline exceeded", 506 | }, 507 | { 508 | name: "database_error", 509 | args: args{ 510 | ctx: t.Context(), 511 | id: "product", 512 | }, 513 | mock: func(t testing.TB) *inventory.MockDB { 514 | ctrl := gomock.NewController(t) 515 | m := inventory.NewMockDB(ctrl) 516 | m.EXPECT().DeleteProduct(gomock.Not(gomock.Nil()), "product").Return(errors.New("unexpected error")) 517 | return m 518 | }, 519 | wantErr: "unexpected error", 520 | }, 521 | } 522 | 523 | for _, tt := range tests { 524 | t.Run(tt.name, func(t *testing.T) { 525 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 526 | var s = service 527 | if tt.mock != nil { 528 | s = inventory.NewService(tt.mock(t)) 529 | } else if s == nil { 530 | t.Skip("required database not found, skipping test") 531 | } 532 | if err := s.DeleteProduct(tt.args.ctx, tt.args.id); err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 533 | t.Errorf("Service.DeleteProduct() error = %v, wantErr %v", err, tt.wantErr) 534 | } 535 | }) 536 | } 537 | } 538 | 539 | func TestServiceGetProduct(t *testing.T) { 540 | t.Parallel() 541 | var service = serviceWithPostgres(t) 542 | createProducts(t, service, []inventory.CreateProductParams{ 543 | { 544 | ID: "product", 545 | Name: "A product name", 546 | Description: "A great description", 547 | Price: 10000, 548 | }, 549 | }) 550 | 551 | type args struct { 552 | ctx context.Context 553 | id string 554 | } 555 | tests := []struct { 556 | name string 557 | args args 558 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 559 | want *inventory.Product 560 | wantErr string 561 | }{ 562 | { 563 | name: "missing_product_id", 564 | args: args{ 565 | ctx: t.Context(), 566 | id: "", 567 | }, 568 | wantErr: "missing product ID", 569 | }, 570 | { 571 | name: "product", 572 | args: args{ 573 | ctx: t.Context(), 574 | id: "product", 575 | }, 576 | want: &inventory.Product{ 577 | ID: "product", 578 | Name: "A product name", 579 | Description: "A great description", 580 | Price: 10000, 581 | CreatedAt: time.Now(), 582 | ModifiedAt: time.Now(), 583 | }, 584 | }, 585 | { 586 | name: "not_found", 587 | args: args{ 588 | ctx: t.Context(), 589 | id: "not_found", 590 | }, 591 | want: nil, 592 | }, 593 | { 594 | name: "canceled_ctx", 595 | args: args{ 596 | ctx: canceledContext(t.Context()), 597 | id: "product", 598 | }, 599 | wantErr: "context canceled", 600 | }, 601 | { 602 | name: "deadline_exceeded_ctx", 603 | args: args{ 604 | ctx: deadlineExceededContext(t.Context()), 605 | id: "product", 606 | }, 607 | wantErr: "context deadline exceeded", 608 | }, 609 | { 610 | name: "database_error", 611 | args: args{ 612 | ctx: t.Context(), 613 | id: "product", 614 | }, 615 | mock: func(t testing.TB) *inventory.MockDB { 616 | ctrl := gomock.NewController(t) 617 | m := inventory.NewMockDB(ctrl) 618 | m.EXPECT().GetProduct(gomock.Not(gomock.Nil()), "product").Return(nil, errors.New("unexpected error")) 619 | return m 620 | }, 621 | wantErr: "unexpected error", 622 | }, 623 | } 624 | 625 | for _, tt := range tests { 626 | t.Run(tt.name, func(t *testing.T) { 627 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 628 | var s = service 629 | if tt.mock != nil { 630 | s = inventory.NewService(tt.mock(t)) 631 | } else if s == nil { 632 | t.Skip("required database not found, skipping test") 633 | } 634 | got, err := s.GetProduct(tt.args.ctx, tt.args.id) 635 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 636 | t.Errorf("Service.GetProduct() error = %v, wantErr %v", err, tt.wantErr) 637 | } 638 | if err != nil { 639 | return 640 | } 641 | if !cmp.Equal(tt.want, got, cmpopts.EquateApproxTime(time.Minute)) { 642 | t.Errorf("value returned by Service.GetProduct() doesn't match: %v", cmp.Diff(tt.want, got)) 643 | } 644 | }) 645 | } 646 | } 647 | 648 | func TestServiceSearchProducts(t *testing.T) { 649 | t.Parallel() 650 | var service = serviceWithPostgres(t) 651 | createProducts(t, service, []inventory.CreateProductParams{ 652 | { 653 | ID: "desk", 654 | Name: "plain desk (home)", 655 | Description: "A plain desk", 656 | Price: 140, 657 | }, 658 | { 659 | ID: "chair", 660 | Name: "office chair", 661 | Description: "Office chair", 662 | Price: 80, 663 | }, 664 | { 665 | ID: "table", 666 | Name: "dining home table", 667 | Description: "dining table", 668 | Price: 120, 669 | }, 670 | { 671 | ID: "bed", 672 | Name: "bed", 673 | Description: "small bed", 674 | Price: 100, 675 | }, 676 | }) 677 | 678 | type args struct { 679 | ctx context.Context 680 | params inventory.SearchProductsParams 681 | } 682 | tests := []struct { 683 | name string 684 | args args 685 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 686 | want *inventory.SearchProductsResponse 687 | wantErr string 688 | }{ 689 | { 690 | name: "product", 691 | args: args{ 692 | ctx: t.Context(), 693 | params: inventory.SearchProductsParams{ 694 | QueryString: "plain desk", 695 | Pagination: inventory.Pagination{ 696 | Limit: 10, 697 | }, 698 | }, 699 | }, 700 | want: &inventory.SearchProductsResponse{ 701 | Items: []*inventory.Product{ 702 | { 703 | ID: "desk", 704 | Name: "plain desk (home)", 705 | Description: "A plain desk", 706 | Price: 140, 707 | CreatedAt: time.Now(), 708 | ModifiedAt: time.Now(), 709 | }, 710 | }, 711 | Total: 1, 712 | }, 713 | wantErr: "", 714 | }, 715 | { 716 | name: "missing_search_term", 717 | args: args{ 718 | ctx: t.Context(), 719 | params: inventory.SearchProductsParams{ 720 | QueryString: "", 721 | Pagination: inventory.Pagination{ 722 | Limit: 10, 723 | }, 724 | }, 725 | }, 726 | wantErr: "missing search string", 727 | }, 728 | { 729 | name: "negative_min_price", 730 | args: args{ 731 | ctx: t.Context(), 732 | params: inventory.SearchProductsParams{ 733 | QueryString: "value", 734 | MinPrice: -1, 735 | Pagination: inventory.Pagination{ 736 | Limit: 10, 737 | }, 738 | }, 739 | }, 740 | wantErr: "min price cannot be negative", 741 | }, 742 | { 743 | name: "negative_max_price", 744 | args: args{ 745 | ctx: t.Context(), 746 | params: inventory.SearchProductsParams{ 747 | QueryString: "value", 748 | MaxPrice: -1, 749 | Pagination: inventory.Pagination{ 750 | Limit: 10, 751 | }, 752 | }, 753 | }, 754 | wantErr: "max price cannot be negative", 755 | }, 756 | { 757 | name: "missing_pagination_limit", 758 | args: args{ 759 | ctx: t.Context(), 760 | params: inventory.SearchProductsParams{ 761 | QueryString: "plain desk", 762 | }, 763 | }, 764 | wantErr: "pagination limit must be at least 1", 765 | }, 766 | { 767 | name: "bad_pagination_offset", 768 | args: args{ 769 | ctx: t.Context(), 770 | params: inventory.SearchProductsParams{ 771 | QueryString: "plain desk", 772 | Pagination: inventory.Pagination{ 773 | Limit: 10, 774 | Offset: -1, 775 | }, 776 | }, 777 | }, 778 | wantErr: "pagination offset cannot be negative", 779 | }, 780 | { 781 | name: "product_very_expensive", 782 | args: args{ 783 | ctx: t.Context(), 784 | params: inventory.SearchProductsParams{ 785 | QueryString: "plain desk", 786 | MinPrice: 900, 787 | Pagination: inventory.Pagination{ 788 | Limit: 10, 789 | }, 790 | }, 791 | }, 792 | want: &inventory.SearchProductsResponse{ 793 | Items: []*inventory.Product{}, 794 | Total: 0, 795 | }, 796 | wantErr: "", 797 | }, 798 | { 799 | name: "home", 800 | args: args{ 801 | ctx: t.Context(), 802 | params: inventory.SearchProductsParams{ 803 | QueryString: "home", 804 | Pagination: inventory.Pagination{ 805 | Limit: 10, 806 | }, 807 | }, 808 | }, 809 | want: &inventory.SearchProductsResponse{ 810 | Items: []*inventory.Product{ 811 | { 812 | ID: "table", 813 | Name: "dining home table", 814 | Description: "dining table", 815 | Price: 120, 816 | CreatedAt: time.Now(), 817 | ModifiedAt: time.Now(), 818 | }, 819 | { 820 | ID: "desk", 821 | Name: "plain desk (home)", 822 | Description: "A plain desk", 823 | Price: 140, 824 | CreatedAt: time.Now(), 825 | ModifiedAt: time.Now(), 826 | }, 827 | }, 828 | Total: 2, 829 | }, 830 | wantErr: "", 831 | }, 832 | { 833 | name: "home_paginated", 834 | args: args{ 835 | ctx: t.Context(), 836 | params: inventory.SearchProductsParams{ 837 | QueryString: "home", 838 | Pagination: inventory.Pagination{ 839 | Limit: 1, 840 | Offset: 1, 841 | }, 842 | }, 843 | }, 844 | want: &inventory.SearchProductsResponse{ 845 | Items: []*inventory.Product{ 846 | { 847 | ID: "desk", 848 | Name: "plain desk (home)", 849 | Description: "A plain desk", 850 | Price: 140, 851 | CreatedAt: time.Now(), 852 | ModifiedAt: time.Now(), 853 | }, 854 | }, 855 | Total: 2, 856 | }, 857 | wantErr: "", 858 | }, 859 | { 860 | name: "home_cheaper", 861 | args: args{ 862 | ctx: t.Context(), 863 | params: inventory.SearchProductsParams{ 864 | QueryString: "home", 865 | MaxPrice: 130, 866 | Pagination: inventory.Pagination{ 867 | Limit: 10, 868 | }, 869 | }, 870 | }, 871 | want: &inventory.SearchProductsResponse{ 872 | Items: []*inventory.Product{ 873 | { 874 | ID: "table", 875 | Name: "dining home table", 876 | Description: "dining table", 877 | Price: 120, 878 | CreatedAt: time.Now(), 879 | ModifiedAt: time.Now(), 880 | }, 881 | }, 882 | Total: 1, 883 | }, 884 | wantErr: "", 885 | }, 886 | { 887 | name: "not_found", 888 | args: args{ 889 | ctx: t.Context(), 890 | params: inventory.SearchProductsParams{ 891 | QueryString: "xyz", 892 | Pagination: inventory.Pagination{ 893 | Limit: 10, 894 | }, 895 | }, 896 | }, 897 | want: &inventory.SearchProductsResponse{ 898 | Items: []*inventory.Product{}, 899 | Total: 0, 900 | }, 901 | }, 902 | { 903 | name: "canceled_ctx", 904 | args: args{ 905 | ctx: canceledContext(t.Context()), 906 | params: inventory.SearchProductsParams{ 907 | QueryString: "xyz", 908 | Pagination: inventory.Pagination{ 909 | Limit: 10, 910 | }, 911 | }, 912 | }, 913 | wantErr: "context canceled", 914 | }, 915 | { 916 | name: "deadline_exceeded_ctx", 917 | args: args{ 918 | ctx: deadlineExceededContext(t.Context()), 919 | params: inventory.SearchProductsParams{ 920 | QueryString: "xyz", 921 | Pagination: inventory.Pagination{ 922 | Limit: 10, 923 | }, 924 | }, 925 | }, 926 | wantErr: "context deadline exceeded", 927 | }, 928 | } 929 | 930 | for _, tt := range tests { 931 | t.Run(tt.name, func(t *testing.T) { 932 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 933 | var s = service 934 | if tt.mock != nil { 935 | s = inventory.NewService(tt.mock(t)) 936 | } else if s == nil { 937 | t.Skip("required database not found, skipping test") 938 | } 939 | got, err := s.SearchProducts(tt.args.ctx, tt.args.params) 940 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 941 | t.Errorf("Service.SearchProducts() error = %v, wantErr %v", err, tt.wantErr) 942 | } 943 | if err != nil { 944 | return 945 | } 946 | if !cmp.Equal(tt.want, got, cmpopts.EquateApproxTime(time.Minute)) { 947 | t.Errorf("value returned by Service.SearchProducts() doesn't match: %v", cmp.Diff(tt.want, got)) 948 | } 949 | }) 950 | } 951 | } 952 | -------------------------------------------------------------------------------- /internal/inventory/mock_db_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/henvic/pgxtutorial/internal/inventory (interfaces: DB) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen --build_flags=--mod=mod -package inventory -destination mock_db_test.go . DB 7 | // 8 | 9 | // Package inventory is a generated GoMock package. 10 | package inventory 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockDB is a mock of DB interface. 20 | type MockDB struct { 21 | ctrl *gomock.Controller 22 | recorder *MockDBMockRecorder 23 | } 24 | 25 | // MockDBMockRecorder is the mock recorder for MockDB. 26 | type MockDBMockRecorder struct { 27 | mock *MockDB 28 | } 29 | 30 | // NewMockDB creates a new mock instance. 31 | func NewMockDB(ctrl *gomock.Controller) *MockDB { 32 | mock := &MockDB{ctrl: ctrl} 33 | mock.recorder = &MockDBMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockDB) EXPECT() *MockDBMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // CreateProduct mocks base method. 43 | func (m *MockDB) CreateProduct(arg0 context.Context, arg1 CreateProductParams) error { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "CreateProduct", arg0, arg1) 46 | ret0, _ := ret[0].(error) 47 | return ret0 48 | } 49 | 50 | // CreateProduct indicates an expected call of CreateProduct. 51 | func (mr *MockDBMockRecorder) CreateProduct(arg0, arg1 any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProduct", reflect.TypeOf((*MockDB)(nil).CreateProduct), arg0, arg1) 54 | } 55 | 56 | // CreateProductReview mocks base method. 57 | func (m *MockDB) CreateProductReview(arg0 context.Context, arg1 CreateProductReviewDBParams) error { 58 | m.ctrl.T.Helper() 59 | ret := m.ctrl.Call(m, "CreateProductReview", arg0, arg1) 60 | ret0, _ := ret[0].(error) 61 | return ret0 62 | } 63 | 64 | // CreateProductReview indicates an expected call of CreateProductReview. 65 | func (mr *MockDBMockRecorder) CreateProductReview(arg0, arg1 any) *gomock.Call { 66 | mr.mock.ctrl.T.Helper() 67 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProductReview", reflect.TypeOf((*MockDB)(nil).CreateProductReview), arg0, arg1) 68 | } 69 | 70 | // DeleteProduct mocks base method. 71 | func (m *MockDB) DeleteProduct(arg0 context.Context, arg1 string) error { 72 | m.ctrl.T.Helper() 73 | ret := m.ctrl.Call(m, "DeleteProduct", arg0, arg1) 74 | ret0, _ := ret[0].(error) 75 | return ret0 76 | } 77 | 78 | // DeleteProduct indicates an expected call of DeleteProduct. 79 | func (mr *MockDBMockRecorder) DeleteProduct(arg0, arg1 any) *gomock.Call { 80 | mr.mock.ctrl.T.Helper() 81 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProduct", reflect.TypeOf((*MockDB)(nil).DeleteProduct), arg0, arg1) 82 | } 83 | 84 | // DeleteProductReview mocks base method. 85 | func (m *MockDB) DeleteProductReview(arg0 context.Context, arg1 string) error { 86 | m.ctrl.T.Helper() 87 | ret := m.ctrl.Call(m, "DeleteProductReview", arg0, arg1) 88 | ret0, _ := ret[0].(error) 89 | return ret0 90 | } 91 | 92 | // DeleteProductReview indicates an expected call of DeleteProductReview. 93 | func (mr *MockDBMockRecorder) DeleteProductReview(arg0, arg1 any) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProductReview", reflect.TypeOf((*MockDB)(nil).DeleteProductReview), arg0, arg1) 96 | } 97 | 98 | // GetProduct mocks base method. 99 | func (m *MockDB) GetProduct(arg0 context.Context, arg1 string) (*Product, error) { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "GetProduct", arg0, arg1) 102 | ret0, _ := ret[0].(*Product) 103 | ret1, _ := ret[1].(error) 104 | return ret0, ret1 105 | } 106 | 107 | // GetProduct indicates an expected call of GetProduct. 108 | func (mr *MockDBMockRecorder) GetProduct(arg0, arg1 any) *gomock.Call { 109 | mr.mock.ctrl.T.Helper() 110 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProduct", reflect.TypeOf((*MockDB)(nil).GetProduct), arg0, arg1) 111 | } 112 | 113 | // GetProductReview mocks base method. 114 | func (m *MockDB) GetProductReview(arg0 context.Context, arg1 string) (*ProductReview, error) { 115 | m.ctrl.T.Helper() 116 | ret := m.ctrl.Call(m, "GetProductReview", arg0, arg1) 117 | ret0, _ := ret[0].(*ProductReview) 118 | ret1, _ := ret[1].(error) 119 | return ret0, ret1 120 | } 121 | 122 | // GetProductReview indicates an expected call of GetProductReview. 123 | func (mr *MockDBMockRecorder) GetProductReview(arg0, arg1 any) *gomock.Call { 124 | mr.mock.ctrl.T.Helper() 125 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProductReview", reflect.TypeOf((*MockDB)(nil).GetProductReview), arg0, arg1) 126 | } 127 | 128 | // GetProductReviews mocks base method. 129 | func (m *MockDB) GetProductReviews(arg0 context.Context, arg1 ProductReviewsParams) (*ProductReviewsResponse, error) { 130 | m.ctrl.T.Helper() 131 | ret := m.ctrl.Call(m, "GetProductReviews", arg0, arg1) 132 | ret0, _ := ret[0].(*ProductReviewsResponse) 133 | ret1, _ := ret[1].(error) 134 | return ret0, ret1 135 | } 136 | 137 | // GetProductReviews indicates an expected call of GetProductReviews. 138 | func (mr *MockDBMockRecorder) GetProductReviews(arg0, arg1 any) *gomock.Call { 139 | mr.mock.ctrl.T.Helper() 140 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProductReviews", reflect.TypeOf((*MockDB)(nil).GetProductReviews), arg0, arg1) 141 | } 142 | 143 | // SearchProducts mocks base method. 144 | func (m *MockDB) SearchProducts(arg0 context.Context, arg1 SearchProductsParams) (*SearchProductsResponse, error) { 145 | m.ctrl.T.Helper() 146 | ret := m.ctrl.Call(m, "SearchProducts", arg0, arg1) 147 | ret0, _ := ret[0].(*SearchProductsResponse) 148 | ret1, _ := ret[1].(error) 149 | return ret0, ret1 150 | } 151 | 152 | // SearchProducts indicates an expected call of SearchProducts. 153 | func (mr *MockDBMockRecorder) SearchProducts(arg0, arg1 any) *gomock.Call { 154 | mr.mock.ctrl.T.Helper() 155 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchProducts", reflect.TypeOf((*MockDB)(nil).SearchProducts), arg0, arg1) 156 | } 157 | 158 | // UpdateProduct mocks base method. 159 | func (m *MockDB) UpdateProduct(arg0 context.Context, arg1 UpdateProductParams) error { 160 | m.ctrl.T.Helper() 161 | ret := m.ctrl.Call(m, "UpdateProduct", arg0, arg1) 162 | ret0, _ := ret[0].(error) 163 | return ret0 164 | } 165 | 166 | // UpdateProduct indicates an expected call of UpdateProduct. 167 | func (mr *MockDBMockRecorder) UpdateProduct(arg0, arg1 any) *gomock.Call { 168 | mr.mock.ctrl.T.Helper() 169 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProduct", reflect.TypeOf((*MockDB)(nil).UpdateProduct), arg0, arg1) 170 | } 171 | 172 | // UpdateProductReview mocks base method. 173 | func (m *MockDB) UpdateProductReview(arg0 context.Context, arg1 UpdateProductReviewParams) error { 174 | m.ctrl.T.Helper() 175 | ret := m.ctrl.Call(m, "UpdateProductReview", arg0, arg1) 176 | ret0, _ := ret[0].(error) 177 | return ret0 178 | } 179 | 180 | // UpdateProductReview indicates an expected call of UpdateProductReview. 181 | func (mr *MockDBMockRecorder) UpdateProductReview(arg0, arg1 any) *gomock.Call { 182 | mr.mock.ctrl.T.Helper() 183 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProductReview", reflect.TypeOf((*MockDB)(nil).UpdateProductReview), arg0, arg1) 184 | } 185 | -------------------------------------------------------------------------------- /internal/inventory/review.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | // ProductReview of a product. 10 | type ProductReview struct { 11 | ID string 12 | ProductID string 13 | ReviewerID string 14 | Score int32 15 | Title string 16 | Description string 17 | CreatedAt time.Time 18 | ModifiedAt time.Time 19 | } 20 | 21 | // CreateProductReviewParams is used when creating the review of a product. 22 | type CreateProductReviewParams struct { 23 | ProductID string 24 | ReviewerID string 25 | Score int32 26 | Title string 27 | Description string 28 | } 29 | 30 | func (p *CreateProductReviewParams) validate() error { 31 | if p.ProductID == "" { 32 | return ValidationError{"missing product ID"} 33 | } 34 | if p.ReviewerID == "" { 35 | return ValidationError{"missing reviewer ID"} 36 | } 37 | if err := validateScore(p.Score); err != nil { 38 | return err 39 | } 40 | if p.Title == "" { 41 | return ValidationError{"missing review title"} 42 | } 43 | if p.Description == "" { 44 | return ValidationError{"missing review description"} 45 | } 46 | return nil 47 | } 48 | 49 | // validateScore checks if the score is between 0 to 5. 50 | func validateScore(score int32) error { 51 | if score < 0 || score > 5 { 52 | return ValidationError{"invalid score"} 53 | } 54 | return nil 55 | } 56 | 57 | // CreateProductReviewParams is used when creating the review of a product in the database. 58 | type CreateProductReviewDBParams struct { 59 | ID string 60 | CreateProductReviewParams 61 | } 62 | 63 | // ErrCreateReviewNoProduct is returned when a product review cannot be created because a product is not found. 64 | var ErrCreateReviewNoProduct = errors.New("cannot find product to create review") 65 | 66 | // CreateProductReview of a product. 67 | func (s *Service) CreateProductReview(ctx context.Context, params CreateProductReviewParams) (id string, err error) { 68 | if err := params.validate(); err != nil { 69 | return "", err 70 | } 71 | 72 | id = newID() 73 | if err := s.db.CreateProductReview(ctx, CreateProductReviewDBParams{ 74 | ID: id, 75 | CreateProductReviewParams: params, 76 | }); err != nil { 77 | return "", err 78 | } 79 | return id, nil 80 | } 81 | 82 | // UpdateProductReviewParams to use when updating an existing review. 83 | type UpdateProductReviewParams struct { 84 | ID string 85 | Score *int32 86 | Title *string 87 | Description *string 88 | } 89 | 90 | func (p *UpdateProductReviewParams) validate() error { 91 | if p.ID == "" { 92 | return ValidationError{"missing review ID"} 93 | } 94 | if p.Score == nil && p.Title == nil && p.Description == nil { 95 | return ValidationError{"no product review arguments to update"} 96 | } 97 | if p.Score != nil { 98 | if err := validateScore(*p.Score); err != nil { 99 | return err 100 | } 101 | } 102 | if p.Title != nil && *p.Title == "" { 103 | return ValidationError{"missing review title"} 104 | } 105 | if p.Description != nil && *p.Description == "" { 106 | return ValidationError{"missing review description"} 107 | } 108 | return nil 109 | } 110 | 111 | // UpdateProductReview of a product. 112 | func (s *Service) UpdateProductReview(ctx context.Context, params UpdateProductReviewParams) error { 113 | if err := params.validate(); err != nil { 114 | return err 115 | } 116 | return s.db.UpdateProductReview(ctx, params) 117 | } 118 | 119 | // DeleteProductReview of a product. 120 | func (s *Service) DeleteProductReview(ctx context.Context, id string) error { 121 | if id == "" { 122 | return ValidationError{"missing review ID"} 123 | } 124 | return s.db.DeleteProductReview(ctx, id) 125 | } 126 | 127 | // GetProductReview gets a product review. 128 | func (s *Service) GetProductReview(ctx context.Context, id string) (*ProductReview, error) { 129 | if id == "" { 130 | return nil, ValidationError{"missing review ID"} 131 | } 132 | return s.db.GetProductReview(ctx, id) 133 | } 134 | 135 | // ProductReviewsParams is used to get a list of reviews. 136 | type ProductReviewsParams struct { 137 | ProductID string 138 | ReviewerID string 139 | Pagination Pagination 140 | } 141 | 142 | // ProductReviewsResponse is the response from GetProductReviews. 143 | type ProductReviewsResponse struct { 144 | Reviews []*ProductReview 145 | Total int 146 | } 147 | 148 | // GetProductReviews gets a list of reviews. 149 | func (s *Service) GetProductReviews(ctx context.Context, params ProductReviewsParams) (*ProductReviewsResponse, error) { 150 | if params.ReviewerID == "" && params.ProductID == "" { 151 | return nil, ValidationError{"missing params: reviewer_id or product_id are required"} 152 | } 153 | return s.db.GetProductReviews(ctx, params) 154 | } 155 | -------------------------------------------------------------------------------- /internal/inventory/review_test.go: -------------------------------------------------------------------------------- 1 | package inventory_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/henvic/pgxtutorial/internal/inventory" 12 | "go.uber.org/mock/gomock" 13 | ) 14 | 15 | func TestServiceCreateProductReview(t *testing.T) { 16 | t.Parallel() 17 | var service = serviceWithPostgres(t) 18 | createProducts(t, service, []inventory.CreateProductParams{ 19 | { 20 | ID: "product", 21 | Name: "Original name", 22 | Description: "This is the original description", 23 | Price: 250, 24 | }, 25 | { 26 | ID: "another", 27 | Name: "Is your SQL UPDATE call correct?", 28 | Description: "Only the price of this one should be modified", 29 | Price: 99, 30 | }, 31 | }) 32 | type args struct { 33 | ctx context.Context 34 | params inventory.CreateProductReviewParams 35 | } 36 | tests := []struct { 37 | name string 38 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 39 | args args 40 | want *inventory.ProductReview 41 | wantErr string 42 | }{ 43 | { 44 | name: "missing_product_id", 45 | args: args{ 46 | ctx: t.Context(), 47 | params: inventory.CreateProductReviewParams{ 48 | ReviewerID: "customer", 49 | Score: 5, 50 | Title: "Anything", 51 | Description: "I don't really know what to say about this product, and am here just for the points.", 52 | }, 53 | }, 54 | wantErr: "missing product ID", 55 | }, 56 | { 57 | name: "missing_reviewer_id", 58 | args: args{ 59 | ctx: t.Context(), 60 | params: inventory.CreateProductReviewParams{ 61 | ProductID: "product", 62 | Score: 5, 63 | Title: "Anything", 64 | Description: "I don't really know what to say about this product, and am here just for the points.", 65 | }, 66 | }, 67 | wantErr: "missing reviewer ID", 68 | }, 69 | { 70 | name: "missing_title", 71 | args: args{ 72 | ctx: t.Context(), 73 | params: inventory.CreateProductReviewParams{ 74 | ProductID: "product", 75 | ReviewerID: "customer", 76 | Score: 5, 77 | Description: "I don't really know what to say about this product, and am here just for the points.", 78 | }, 79 | }, 80 | wantErr: "missing review title", 81 | }, 82 | { 83 | name: "missing_description", 84 | args: args{ 85 | ctx: t.Context(), 86 | params: inventory.CreateProductReviewParams{ 87 | ProductID: "product", 88 | ReviewerID: "customer", 89 | Score: 5, 90 | Title: "Anything", 91 | }, 92 | }, 93 | wantErr: "missing review description", 94 | }, 95 | { 96 | name: "invalid_score", 97 | args: args{ 98 | ctx: t.Context(), 99 | params: inventory.CreateProductReviewParams{ 100 | ProductID: "product", 101 | ReviewerID: "customer", 102 | Score: 51, 103 | Title: "Anything", 104 | Description: "I don't really know what to say about this product, and am here just for the points.", 105 | }, 106 | }, 107 | wantErr: "invalid score", 108 | }, 109 | { 110 | name: "success", 111 | args: args{ 112 | ctx: t.Context(), 113 | params: inventory.CreateProductReviewParams{ 114 | ProductID: "product", 115 | ReviewerID: "customer", 116 | Score: 5, 117 | Title: "Anything", 118 | Description: "I don't really know what to say about this product, and am here just for the points.", 119 | }, 120 | }, 121 | want: &inventory.ProductReview{ 122 | ProductID: "product", 123 | ReviewerID: "customer", 124 | Score: 5, 125 | Title: "Anything", 126 | Description: "I don't really know what to say about this product, and am here just for the points.", 127 | CreatedAt: time.Now(), 128 | ModifiedAt: time.Now(), 129 | }, 130 | wantErr: "", 131 | }, 132 | { 133 | name: "canceled_ctx", 134 | args: args{ 135 | ctx: canceledContext(t.Context()), 136 | params: inventory.CreateProductReviewParams{ 137 | ProductID: "product", 138 | ReviewerID: "customer", 139 | Score: 5, 140 | Title: "Anything", 141 | Description: "I don't really know what to say about this product, and am here just for the points.", 142 | }, 143 | }, 144 | wantErr: "context canceled", 145 | }, 146 | { 147 | name: "database_error", 148 | mock: func(t testing.TB) *inventory.MockDB { 149 | ctrl := gomock.NewController(t) 150 | m := inventory.NewMockDB(ctrl) 151 | m.EXPECT().CreateProductReview( 152 | gomock.Not(gomock.Nil()), 153 | gomock.Any(), 154 | ).Return(errors.New("unexpected error")) 155 | return m 156 | }, 157 | args: args{ 158 | ctx: t.Context(), 159 | params: inventory.CreateProductReviewParams{ 160 | ProductID: "product", 161 | ReviewerID: "customer", 162 | Score: 5, 163 | Title: "Anything", 164 | Description: "I don't really know what to say about this product, and am here just for the points.", 165 | }, 166 | }, 167 | wantErr: "unexpected error", 168 | }, 169 | } 170 | 171 | for _, tt := range tests { 172 | t.Run(tt.name, func(t *testing.T) { 173 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 174 | var s = service 175 | if tt.mock != nil { 176 | s = inventory.NewService(tt.mock(t)) 177 | } else if s == nil { 178 | t.Skip("required database not found, skipping test") 179 | } 180 | id, err := s.CreateProductReview(tt.args.ctx, tt.args.params) 181 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 182 | t.Errorf("Service.CreateProductReview() error = %v, wantErr %v", err, tt.wantErr) 183 | } 184 | if err != nil { 185 | return 186 | } 187 | if id == "" { 188 | t.Errorf("Service.CreateProductReview() returned empty ID") 189 | } 190 | // Only check integration / real implementation database for data. 191 | if tt.mock != nil { 192 | return 193 | } 194 | // Reusing GetProductReview to check if the product was created successfully. 195 | got, err := s.GetProductReview(tt.args.ctx, id) 196 | if err != nil { 197 | t.Errorf("Service.GetProductReview() error = %v", err) 198 | } 199 | if !cmp.Equal(tt.want, got, cmpopts.IgnoreFields(inventory.ProductReview{}, "ID"), cmpopts.EquateApproxTime(time.Minute)) { 200 | t.Errorf("value returned by Service.GetProductReview() doesn't match: %v", cmp.Diff(tt.want, got)) 201 | } 202 | }) 203 | } 204 | } 205 | 206 | func TestServiceUpdateProductReview(t *testing.T) { 207 | t.Parallel() 208 | var service = serviceWithPostgres(t) 209 | createProducts(t, service, []inventory.CreateProductParams{ 210 | { 211 | ID: "product", 212 | Name: "Original name", 213 | Description: "This is the original description", 214 | Price: 250, 215 | }, 216 | { 217 | ID: "another", 218 | Name: "Is your SQL UPDATE call correct?", 219 | Description: "Only the price of this one should be modified", 220 | Price: 99, 221 | }, 222 | }) 223 | // Add some product reviews that will be modified next: 224 | firstReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 225 | ProductID: "product", 226 | ReviewerID: "you", 227 | Score: 4, 228 | Title: "My title", 229 | Description: "My description", 230 | }) 231 | secondReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 232 | ProductID: "product", 233 | ReviewerID: "me", 234 | Score: 1, 235 | Title: "three little birds", 236 | Description: "don't worry, about a thing", 237 | }) 238 | type args struct { 239 | ctx context.Context 240 | params inventory.UpdateProductReviewParams 241 | } 242 | tests := []struct { 243 | name string 244 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 245 | args args 246 | want *inventory.ProductReview 247 | wantErr string 248 | }{ 249 | { 250 | name: "empty", 251 | args: args{ 252 | ctx: t.Context(), 253 | params: inventory.UpdateProductReviewParams{}, 254 | }, 255 | wantErr: "missing review ID", 256 | }, 257 | { 258 | name: "invalid_review_score", 259 | args: args{ 260 | ctx: t.Context(), 261 | params: inventory.UpdateProductReviewParams{ 262 | ID: "invalid_review_score", 263 | Score: ptr(int32(-5)), 264 | }, 265 | }, 266 | wantErr: "invalid score", 267 | }, 268 | { 269 | name: "no_product_review_title", 270 | args: args{ 271 | ctx: t.Context(), 272 | params: inventory.UpdateProductReviewParams{ 273 | ID: "no_product_review_title", 274 | Title: ptr(""), 275 | }, 276 | }, 277 | wantErr: "missing review title", 278 | }, 279 | { 280 | name: "no_product_review_description", 281 | args: args{ 282 | ctx: t.Context(), 283 | params: inventory.UpdateProductReviewParams{ 284 | ID: "no_product_review_desc", 285 | Description: ptr(""), 286 | }, 287 | }, 288 | wantErr: "missing review description", 289 | }, 290 | { 291 | name: "not_found", 292 | args: args{ 293 | ctx: t.Context(), 294 | params: inventory.UpdateProductReviewParams{ 295 | ID: "World", 296 | Title: ptr("Earth"), 297 | }, 298 | }, 299 | wantErr: "product review not found", 300 | }, 301 | { 302 | name: "canceled_ctx", 303 | args: args{ 304 | ctx: canceledContext(t.Context()), 305 | params: inventory.UpdateProductReviewParams{ 306 | ID: "product_review", 307 | Title: ptr("Earth"), 308 | }, 309 | }, 310 | wantErr: "context canceled", 311 | }, 312 | { 313 | name: "deadline_exceeded_ctx", 314 | args: args{ 315 | ctx: deadlineExceededContext(t.Context()), 316 | params: inventory.UpdateProductReviewParams{ 317 | ID: "product_review", 318 | Title: ptr("Earth"), 319 | }, 320 | }, 321 | wantErr: "context deadline exceeded", 322 | }, 323 | { 324 | name: "no_changes", 325 | args: args{ 326 | ctx: t.Context(), 327 | params: inventory.UpdateProductReviewParams{ 328 | ID: "no_changes", 329 | }, 330 | }, 331 | wantErr: "no product review arguments to update", 332 | }, 333 | { 334 | name: "success", 335 | args: args{ 336 | ctx: t.Context(), 337 | params: inventory.UpdateProductReviewParams{ 338 | ID: firstReviewID, 339 | Score: ptr(int32(3)), 340 | Title: ptr("updated title"), 341 | Description: ptr("updated desc"), 342 | }, 343 | }, 344 | want: &inventory.ProductReview{ 345 | ProductID: "product", 346 | ReviewerID: "you", 347 | Score: 3, 348 | Title: "updated title", 349 | Description: "updated desc", 350 | }, 351 | }, 352 | { 353 | name: "success_score", 354 | args: args{ 355 | ctx: t.Context(), 356 | params: inventory.UpdateProductReviewParams{ 357 | ID: secondReviewID, 358 | Score: ptr(int32(5)), 359 | }, 360 | }, 361 | want: &inventory.ProductReview{ 362 | ProductID: "product", 363 | ReviewerID: "me", 364 | Score: 5, 365 | Title: "three little birds", 366 | Description: "don't worry, about a thing", 367 | }, 368 | }, 369 | { 370 | name: "database_error", 371 | mock: func(t testing.TB) *inventory.MockDB { 372 | ctrl := gomock.NewController(t) 373 | m := inventory.NewMockDB(ctrl) 374 | m.EXPECT().UpdateProductReview(gomock.Not(gomock.Nil()), 375 | inventory.UpdateProductReviewParams{ 376 | ID: "simple", 377 | Title: ptr("product review title"), 378 | Description: ptr("product review description"), 379 | }).Return(errors.New("unexpected error")) 380 | return m 381 | }, 382 | args: args{ 383 | ctx: t.Context(), 384 | params: inventory.UpdateProductReviewParams{ 385 | ID: "simple", 386 | Title: ptr("product review title"), 387 | Description: ptr("product review description"), 388 | }, 389 | }, 390 | wantErr: "unexpected error", 391 | }, 392 | } 393 | 394 | for _, tt := range tests { 395 | t.Run(tt.name, func(t *testing.T) { 396 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 397 | var s = service 398 | if tt.mock != nil { 399 | s = inventory.NewService(tt.mock(t)) 400 | } else if s == nil { 401 | t.Skip("required database not found, skipping test") 402 | } 403 | err := s.UpdateProductReview(tt.args.ctx, tt.args.params) 404 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 405 | t.Errorf("Service.UpdateProductReview() error = %v, wantErr %v", err, tt.wantErr) 406 | } 407 | if err != nil { 408 | return 409 | } 410 | got, err := s.GetProductReview(tt.args.ctx, tt.args.params.ID) 411 | if err != nil { 412 | t.Errorf("Service.GetProductReview() error = %v", err) 413 | } 414 | if got.CreatedAt.IsZero() { 415 | t.Error("Service.GetProductReview() returned CreatedAt should not be zero") 416 | } 417 | if !got.CreatedAt.Before(got.ModifiedAt) { 418 | t.Error("Service.GetProductReview() should return CreatedAt < ModifiedAt") 419 | } 420 | // Copy CreatedAt and ModifiedAt before comparing structs. 421 | // See TestCreateProduct for a strategy using cmpopts.EquateApproxTime instead. 422 | tt.want.CreatedAt = got.CreatedAt 423 | tt.want.ModifiedAt = got.ModifiedAt 424 | if !cmp.Equal(tt.want, got, cmpopts.IgnoreFields(inventory.ProductReview{}, "ID")) { 425 | t.Errorf("value returned by Service.GetProductReview() doesn't match: %v", cmp.Diff(tt.want, got)) 426 | } 427 | }) 428 | } 429 | } 430 | 431 | func TestServiceDeleteProductReview(t *testing.T) { 432 | t.Parallel() 433 | var service = serviceWithPostgres(t) 434 | createProducts(t, service, []inventory.CreateProductParams{ 435 | { 436 | ID: "product", 437 | Name: "Product name", 438 | Description: "Product description", 439 | Price: 123, 440 | }, 441 | }) 442 | reviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 443 | ProductID: "product", 444 | ReviewerID: "you", 445 | Score: 4, 446 | Title: "My title", 447 | Description: "My description", 448 | }) 449 | 450 | type args struct { 451 | ctx context.Context 452 | id string 453 | } 454 | tests := []struct { 455 | name string 456 | args args 457 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 458 | wantErr string 459 | }{ 460 | { 461 | name: "missing_product_review_id", 462 | args: args{ 463 | ctx: t.Context(), 464 | id: "", 465 | }, 466 | wantErr: "missing review ID", 467 | }, 468 | { 469 | name: "success", 470 | args: args{ 471 | ctx: t.Context(), 472 | id: reviewID, 473 | }, 474 | wantErr: "", 475 | }, 476 | // calling delete multiple times should not fail 477 | { 478 | name: "already_deleted", 479 | args: args{ 480 | ctx: t.Context(), 481 | id: reviewID, 482 | }, 483 | wantErr: "", 484 | }, 485 | // delete should be idempotent 486 | { 487 | name: "not_found", 488 | args: args{ 489 | ctx: t.Context(), 490 | id: "xyz", 491 | }, 492 | }, 493 | { 494 | name: "canceled_ctx", 495 | args: args{ 496 | ctx: canceledContext(t.Context()), 497 | id: "abc", 498 | }, 499 | wantErr: "context canceled", 500 | }, 501 | { 502 | name: "deadline_exceeded_ctx", 503 | args: args{ 504 | ctx: deadlineExceededContext(t.Context()), 505 | id: "def", 506 | }, 507 | wantErr: "context deadline exceeded", 508 | }, 509 | { 510 | name: "database_error", 511 | args: args{ 512 | ctx: t.Context(), 513 | id: "ghi", 514 | }, 515 | mock: func(t testing.TB) *inventory.MockDB { 516 | ctrl := gomock.NewController(t) 517 | m := inventory.NewMockDB(ctrl) 518 | m.EXPECT().DeleteProductReview(gomock.Not(gomock.Nil()), "ghi").Return(errors.New("unexpected error")) 519 | return m 520 | }, 521 | wantErr: "unexpected error", 522 | }, 523 | } 524 | 525 | for _, tt := range tests { 526 | t.Run(tt.name, func(t *testing.T) { 527 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 528 | var s = service 529 | if tt.mock != nil { 530 | s = inventory.NewService(tt.mock(t)) 531 | } else if s == nil { 532 | t.Skip("required database not found, skipping test") 533 | } 534 | if err := s.DeleteProductReview(tt.args.ctx, tt.args.id); err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 535 | t.Errorf("Service.DeleteProductReview() error = %v, wantErr %v", err, tt.wantErr) 536 | } 537 | }) 538 | } 539 | } 540 | 541 | func TestServiceGetProductReview(t *testing.T) { 542 | t.Parallel() 543 | var service = serviceWithPostgres(t) 544 | createProducts(t, service, []inventory.CreateProductParams{ 545 | { 546 | ID: "product", 547 | Name: "A product name", 548 | Description: "A great description", 549 | Price: 10000, 550 | }, 551 | }) 552 | // Add some product reviews that will be modified next: 553 | firstReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 554 | ProductID: "product", 555 | ReviewerID: "you", 556 | Score: 4, 557 | Title: "My title", 558 | Description: "My description", 559 | }) 560 | secondReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 561 | ProductID: "product", 562 | ReviewerID: "me", 563 | Score: 1, 564 | Title: "three little birds", 565 | Description: "don't worry, about a thing", 566 | }) 567 | type args struct { 568 | ctx context.Context 569 | id string 570 | } 571 | tests := []struct { 572 | name string 573 | args args 574 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 575 | want *inventory.ProductReview 576 | wantErr string 577 | }{ 578 | { 579 | name: "missing_product_review_id", 580 | args: args{ 581 | ctx: t.Context(), 582 | id: "", 583 | }, 584 | wantErr: "missing review ID", 585 | }, 586 | { 587 | name: "success", 588 | args: args{ 589 | ctx: t.Context(), 590 | id: firstReviewID, 591 | }, 592 | want: &inventory.ProductReview{ 593 | ProductID: "product", 594 | ReviewerID: "you", 595 | Score: 4, 596 | Title: "My title", 597 | Description: "My description", 598 | CreatedAt: time.Now(), 599 | ModifiedAt: time.Now(), 600 | }, 601 | }, 602 | { 603 | name: "other", 604 | args: args{ 605 | ctx: t.Context(), 606 | id: secondReviewID, 607 | }, 608 | want: &inventory.ProductReview{ 609 | ProductID: "product", 610 | ReviewerID: "me", 611 | Score: 1, 612 | Title: "three little birds", 613 | Description: "don't worry, about a thing", 614 | CreatedAt: time.Now(), 615 | ModifiedAt: time.Now(), 616 | }, 617 | }, 618 | { 619 | name: "not_found", 620 | args: args{ 621 | ctx: t.Context(), 622 | id: "not_found", 623 | }, 624 | want: nil, 625 | }, 626 | { 627 | name: "canceled_ctx", 628 | args: args{ 629 | ctx: canceledContext(t.Context()), 630 | id: "review_id", 631 | }, 632 | wantErr: "context canceled", 633 | }, 634 | { 635 | name: "deadline_exceeded_ctx", 636 | args: args{ 637 | ctx: deadlineExceededContext(t.Context()), 638 | id: "review_id", 639 | }, 640 | wantErr: "context deadline exceeded", 641 | }, 642 | { 643 | name: "database_error", 644 | args: args{ 645 | ctx: t.Context(), 646 | id: "review_id", 647 | }, 648 | mock: func(t testing.TB) *inventory.MockDB { 649 | ctrl := gomock.NewController(t) 650 | m := inventory.NewMockDB(ctrl) 651 | m.EXPECT().GetProductReview(gomock.Not(gomock.Nil()), "review_id").Return(nil, errors.New("unexpected error")) 652 | return m 653 | }, 654 | wantErr: "unexpected error", 655 | }, 656 | } 657 | 658 | for _, tt := range tests { 659 | t.Run(tt.name, func(t *testing.T) { 660 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 661 | var s = service 662 | if tt.mock != nil { 663 | s = inventory.NewService(tt.mock(t)) 664 | } else if s == nil { 665 | t.Skip("required database not found, skipping test") 666 | } 667 | got, err := s.GetProductReview(tt.args.ctx, tt.args.id) 668 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 669 | t.Errorf("Service.GetProductReview() error = %v, wantErr %v", err, tt.wantErr) 670 | } 671 | if err != nil { 672 | return 673 | } 674 | if !cmp.Equal(tt.want, got, 675 | cmpopts.IgnoreFields(inventory.ProductReview{}, "ID"), 676 | cmpopts.EquateApproxTime(time.Minute)) { 677 | t.Errorf("value returned by Service.GetProductReview() doesn't match: %v", cmp.Diff(tt.want, got)) 678 | } 679 | }) 680 | } 681 | } 682 | 683 | func TestServiceGetProductReviews(t *testing.T) { 684 | t.Parallel() 685 | var service = serviceWithPostgres(t) 686 | createProducts(t, service, []inventory.CreateProductParams{ 687 | { 688 | ID: "chair", 689 | Name: "Office chair with headrest", 690 | Description: "The best chair for your neck.", 691 | Price: 200, 692 | }, 693 | }) 694 | createProducts(t, service, []inventory.CreateProductParams{ 695 | { 696 | ID: "desk", 697 | Name: "study desk", 698 | Description: "A beautiful desk.", 699 | Price: 1400, 700 | }, 701 | }) 702 | hackerDeskReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 703 | ProductID: "desk", 704 | ReviewerID: "hacker", 705 | Score: 5, 706 | Title: "Solid work of art", 707 | Description: "I built it with the best wood I could find.", 708 | }) 709 | ironworkerReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 710 | ProductID: "desk", 711 | ReviewerID: "ironworker", 712 | Score: 4, 713 | Title: "Very Good", 714 | Description: "Nice steady study desk, but I bet it'd last much longer with a steel base.", 715 | }) 716 | candlemakerReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 717 | ProductID: "desk", 718 | ReviewerID: "candlemaker", 719 | Score: 4, 720 | Title: "Perfect", 721 | Description: "Good affordable desk. You should spread wax to polish it.", 722 | }) 723 | glazierReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 724 | ProductID: "desk", 725 | ReviewerID: "glazier", 726 | Score: 1, 727 | Title: "Excellent, not.", 728 | Description: "I prefer desks made out of glass, as I'm jealous of the tailor and the baker.", 729 | }) 730 | hackerChairReviewID := createProductReview(t, service, inventory.CreateProductReviewParams{ 731 | ProductID: "chair", 732 | ReviewerID: "hacker", 733 | Score: 3, 734 | Title: "Nice chair", 735 | Description: "Comfy chair. I recommend.", 736 | }) 737 | 738 | type args struct { 739 | ctx context.Context 740 | params inventory.ProductReviewsParams 741 | } 742 | tests := []struct { 743 | name string 744 | args args 745 | mock func(t testing.TB) *inventory.MockDB // Leave as nil for using a real database implementation. 746 | want *inventory.ProductReviewsResponse 747 | wantErr string 748 | }{ 749 | { 750 | name: "success", 751 | args: args{ 752 | ctx: t.Context(), 753 | params: inventory.ProductReviewsParams{ 754 | ProductID: "desk", 755 | Pagination: inventory.Pagination{ 756 | Limit: 3, 757 | }, 758 | }, 759 | }, 760 | want: &inventory.ProductReviewsResponse{ 761 | Reviews: []*inventory.ProductReview{ 762 | { 763 | ID: glazierReviewID, 764 | ProductID: "desk", 765 | ReviewerID: "glazier", 766 | Score: 1, 767 | Title: "Excellent, not.", 768 | Description: "I prefer desks made out of glass, as I'm jealous of the tailor and the baker.", 769 | CreatedAt: time.Now(), 770 | ModifiedAt: time.Now(), 771 | }, 772 | { 773 | ID: candlemakerReviewID, 774 | ProductID: "desk", 775 | ReviewerID: "candlemaker", 776 | Score: 4, 777 | Title: "Perfect", 778 | Description: "Good affordable desk. You should spread wax to polish it.", 779 | CreatedAt: time.Now(), 780 | ModifiedAt: time.Now(), 781 | }, 782 | { 783 | ID: ironworkerReviewID, 784 | ProductID: "desk", 785 | ReviewerID: "ironworker", 786 | Score: 4, 787 | Title: "Very Good", 788 | Description: "Nice steady study desk, but I bet it'd last much longer with a steel base.", 789 | CreatedAt: time.Now(), 790 | ModifiedAt: time.Now(), 791 | }, 792 | }, 793 | Total: 4, 794 | }, 795 | }, 796 | { 797 | name: "reviewer", 798 | args: args{ 799 | ctx: t.Context(), 800 | params: inventory.ProductReviewsParams{ 801 | ReviewerID: "hacker", 802 | Pagination: inventory.Pagination{ 803 | Limit: 3, 804 | }, 805 | }, 806 | }, 807 | want: &inventory.ProductReviewsResponse{ 808 | Reviews: []*inventory.ProductReview{ 809 | { 810 | ID: hackerChairReviewID, 811 | ProductID: "chair", 812 | ReviewerID: "hacker", 813 | Score: 3, 814 | Title: "Nice chair", 815 | Description: "Comfy chair. I recommend.", 816 | CreatedAt: time.Now(), 817 | ModifiedAt: time.Now(), 818 | }, 819 | { 820 | ID: hackerDeskReviewID, 821 | ProductID: "desk", 822 | ReviewerID: "hacker", 823 | Score: 5, 824 | Title: "Solid work of art", 825 | Description: "I built it with the best wood I could find.", 826 | CreatedAt: time.Now(), 827 | ModifiedAt: time.Now(), 828 | }, 829 | }, 830 | Total: 2, 831 | }, 832 | }, 833 | { 834 | name: "missing_params", 835 | args: args{ 836 | ctx: t.Context(), 837 | params: inventory.ProductReviewsParams{ 838 | Pagination: inventory.Pagination{ 839 | Limit: 10, 840 | }, 841 | }, 842 | }, 843 | wantErr: "missing params: reviewer_id or product_id are required", 844 | }, 845 | { 846 | name: "not_found", 847 | args: args{ 848 | ctx: t.Context(), 849 | params: inventory.ProductReviewsParams{ 850 | ProductID: "wardrobe", 851 | Pagination: inventory.Pagination{ 852 | Limit: 10, 853 | }, 854 | }, 855 | }, 856 | want: &inventory.ProductReviewsResponse{ 857 | Reviews: []*inventory.ProductReview{}, 858 | Total: 0, 859 | }, 860 | }, 861 | { 862 | name: "canceled_ctx", 863 | args: args{ 864 | ctx: canceledContext(t.Context()), 865 | params: inventory.ProductReviewsParams{ 866 | ProductID: "bench", 867 | Pagination: inventory.Pagination{ 868 | Limit: 10, 869 | }, 870 | }, 871 | }, 872 | wantErr: "context canceled", 873 | }, 874 | { 875 | name: "deadline_exceeded_ctx", 876 | args: args{ 877 | ctx: deadlineExceededContext(t.Context()), 878 | params: inventory.ProductReviewsParams{ 879 | ReviewerID: "glazier", 880 | Pagination: inventory.Pagination{ 881 | Limit: 10, 882 | }, 883 | }, 884 | }, 885 | wantErr: "context deadline exceeded", 886 | }, 887 | } 888 | 889 | for _, tt := range tests { 890 | t.Run(tt.name, func(t *testing.T) { 891 | // If tt.mock is nil, use real database implementation if available. Otherwise, skip the test. 892 | var s = service 893 | if tt.mock != nil { 894 | s = inventory.NewService(tt.mock(t)) 895 | } else if s == nil { 896 | t.Skip("required database not found, skipping test") 897 | } 898 | got, err := s.GetProductReviews(tt.args.ctx, tt.args.params) 899 | if err == nil && tt.wantErr != "" || err != nil && tt.wantErr != err.Error() { 900 | t.Errorf("Service.GetProductReviews() error = %v, wantErr %v", err, tt.wantErr) 901 | } 902 | if err != nil { 903 | return 904 | } 905 | if !cmp.Equal(tt.want, got, cmpopts.EquateApproxTime(time.Minute)) { 906 | t.Errorf("value returned by Service.GetProductReviews() doesn't match: %v", cmp.Diff(tt.want, got)) 907 | } 908 | }) 909 | } 910 | } 911 | -------------------------------------------------------------------------------- /internal/inventory/service.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | ) 7 | 8 | // NewService creates an API service. 9 | func NewService(db DB) *Service { 10 | return &Service{db: db} 11 | } 12 | 13 | // Service for the API. 14 | type Service struct { 15 | db DB 16 | } 17 | 18 | // DB layer. 19 | // 20 | //go:generate mockgen --build_flags=--mod=mod -package inventory -destination mock_db_test.go . DB 21 | type DB interface { 22 | // CreateProduct creates a new product. 23 | CreateProduct(ctx context.Context, params CreateProductParams) error 24 | 25 | // UpdateProduct updates an existing product. 26 | UpdateProduct(ctx context.Context, params UpdateProductParams) error 27 | 28 | // GetProduct returns a product. 29 | GetProduct(ctx context.Context, id string) (*Product, error) 30 | 31 | // SearchProducts returns a list of products. 32 | SearchProducts(ctx context.Context, params SearchProductsParams) (*SearchProductsResponse, error) 33 | 34 | // DeleteProduct deletes a product. 35 | DeleteProduct(ctx context.Context, id string) error 36 | 37 | // CreateProductReview for a given product. 38 | CreateProductReview(ctx context.Context, params CreateProductReviewDBParams) error 39 | 40 | // UpdateProductReview for a given product. 41 | UpdateProductReview(ctx context.Context, params UpdateProductReviewParams) error 42 | 43 | // GetProductReview gets a specific review. 44 | GetProductReview(ctx context.Context, id string) (*ProductReview, error) 45 | 46 | // GetProductReviews gets reviews for a given product or from a given user. 47 | GetProductReviews(ctx context.Context, params ProductReviewsParams) (*ProductReviewsResponse, error) 48 | 49 | // DeleteProductReview deletes a review. 50 | DeleteProductReview(ctx context.Context, id string) error 51 | } 52 | 53 | // ValidationError is returned when there is an invalid parameter received. 54 | type ValidationError struct { 55 | s string 56 | } 57 | 58 | func (e ValidationError) Error() string { 59 | return e.s 60 | } 61 | 62 | // Pagination is used to paginate results. 63 | // 64 | // Usage: 65 | // 66 | // Pagination{ 67 | // Limit: limit, 68 | // Offset: (page - 1) * limit 69 | // } 70 | type Pagination struct { 71 | // Limit is the maximum number of results to return on this page. 72 | Limit int 73 | 74 | // Offset is the number of results to skip from the beginning of the results. 75 | // Typically: (page number - 1) * limit. 76 | Offset int 77 | } 78 | 79 | // Validate pagination. 80 | func (p *Pagination) Validate() error { 81 | if p.Limit < 1 { 82 | return ValidationError{"pagination limit must be at least 1"} 83 | } 84 | if p.Offset < 0 { 85 | return ValidationError{"pagination offset cannot be negative"} 86 | } 87 | return nil 88 | } 89 | 90 | // newID generates a random base-58 ID. 91 | func newID() string { 92 | const ( 93 | alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" // base58 94 | size = 11 95 | ) 96 | var id = make([]byte, size) 97 | if _, err := rand.Read(id); err != nil { 98 | panic(err) 99 | } 100 | for i, p := range id { 101 | id[i] = alphabet[int(p)%len(alphabet)] // discard everything but the least significant bits 102 | } 103 | return string(id) 104 | } 105 | -------------------------------------------------------------------------------- /internal/inventory/service_test.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import "testing" 4 | 5 | func TestPaginationValidate(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | pagination Pagination 9 | wantErr bool 10 | }{ 11 | { 12 | name: "valid_begin", 13 | pagination: Pagination{ 14 | Limit: 10, 15 | Offset: 0, 16 | }, 17 | }, 18 | { 19 | name: "one", 20 | pagination: Pagination{ 21 | Limit: 1, 22 | Offset: 0, 23 | }, 24 | }, 25 | { 26 | name: "valid_high", 27 | pagination: Pagination{ 28 | Limit: 150, 29 | Offset: 240, 30 | }, 31 | }, 32 | { 33 | name: "negative_limit", 34 | pagination: Pagination{ 35 | Limit: -10, 36 | Offset: 0, 37 | }, 38 | wantErr: true, 39 | }, 40 | { 41 | name: "negative_offset", 42 | pagination: Pagination{ 43 | Limit: 10, 44 | Offset: -10, 45 | }, 46 | wantErr: true, 47 | }, 48 | { 49 | name: "negative_both", 50 | pagination: Pagination{ 51 | Limit: -10, 52 | Offset: -5, 53 | }, 54 | wantErr: true, 55 | }, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | if err := tt.pagination.Validate(); (err != nil) != tt.wantErr { 60 | t.Errorf("Pagination.Validate() error = %v, wantErr %v", err, tt.wantErr) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/postgres/helper_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/henvic/pgxtutorial/internal/inventory" 9 | ) 10 | 11 | func createProducts(t testing.TB, db DB, products []inventory.CreateProductParams) { 12 | for _, p := range products { 13 | if err := db.CreateProduct(t.Context(), p); err != nil { 14 | t.Errorf("DB.CreateProduct() error = %v", err) 15 | } 16 | } 17 | } 18 | 19 | func createProductReviews(t testing.TB, db DB, reviews []inventory.CreateProductReviewDBParams) { 20 | for _, r := range reviews { 21 | if err := db.CreateProductReview(t.Context(), r); err != nil { 22 | t.Errorf("DB.CreateProductReview() error = %v", err) 23 | } 24 | } 25 | } 26 | 27 | func canceledContext(ctx context.Context) context.Context { 28 | ctx, cancel := context.WithCancel(ctx) 29 | cancel() 30 | return ctx 31 | } 32 | 33 | func deadlineExceededContext(ctx context.Context) context.Context { 34 | ctx, cancel := context.WithTimeout(ctx, -time.Second) 35 | cancel() 36 | return ctx 37 | } 38 | 39 | // ptr returns a pointer to the given value. 40 | func ptr[T any](v T) *T { 41 | return &v 42 | } 43 | -------------------------------------------------------------------------------- /internal/postgres/postgres.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "github.com/henvic/pgtools" 12 | "github.com/henvic/pgxtutorial/internal/database" 13 | "github.com/henvic/pgxtutorial/internal/inventory" 14 | "github.com/jackc/pgerrcode" 15 | "github.com/jackc/pgx/v5" 16 | "github.com/jackc/pgx/v5/pgconn" 17 | "github.com/jackc/pgx/v5/pgxpool" 18 | ) 19 | 20 | // DB handles database communication with PostgreSQL. 21 | type DB struct { 22 | // pool for accessing Postgres database.PGX 23 | pool *pgxpool.Pool 24 | 25 | // log is a log for the operations. 26 | log *slog.Logger 27 | } 28 | 29 | // NewDB creates a DB. 30 | func NewDB(pool *pgxpool.Pool, logger *slog.Logger) DB { 31 | return DB{ 32 | pool: pool, 33 | log: logger, 34 | } 35 | } 36 | 37 | // TransactionContext returns a copy of the parent context which begins a transaction 38 | // to PostgreSQL. 39 | // 40 | // Once the transaction is over, you must call db.Commit(ctx) to make the changes effective. 41 | // This might live in the go-pkg/postgres package later for the sake of code reuse. 42 | func (db DB) TransactionContext(ctx context.Context) (context.Context, error) { 43 | tx, err := db.conn(ctx).Begin(ctx) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return context.WithValue(ctx, txCtx{}, tx), nil 48 | } 49 | 50 | // Commit transaction from context. 51 | func (db DB) Commit(ctx context.Context) error { 52 | if tx, ok := ctx.Value(txCtx{}).(pgx.Tx); ok && tx != nil { 53 | return tx.Commit(ctx) 54 | } 55 | return errors.New("context has no transaction") 56 | } 57 | 58 | // Rollback transaction from context. 59 | func (db DB) Rollback(ctx context.Context) error { 60 | if tx, ok := ctx.Value(txCtx{}).(pgx.Tx); ok && tx != nil { 61 | return tx.Rollback(ctx) 62 | } 63 | return errors.New("context has no transaction") 64 | } 65 | 66 | // WithAcquire returns a copy of the parent context which acquires a connection 67 | // to PostgreSQL from pgxpool to make sure commands executed in series reuse the 68 | // same database connection. 69 | // 70 | // To release the connection back to the pool, you must call postgres.Release(ctx). 71 | // 72 | // Example: 73 | // dbCtx := db.WithAcquire(ctx) 74 | // defer postgres.Release(dbCtx) 75 | func (db DB) WithAcquire(ctx context.Context) (dbCtx context.Context, err error) { 76 | if _, ok := ctx.Value(connCtx{}).(*pgxpool.Conn); ok { 77 | panic("context already has a connection acquired") 78 | } 79 | res, err := db.pool.Acquire(ctx) 80 | if err != nil { 81 | return nil, err 82 | } 83 | return context.WithValue(ctx, connCtx{}, res), nil 84 | } 85 | 86 | // Release PostgreSQL connection acquired by context back to the pool. 87 | func (db DB) Release(ctx context.Context) { 88 | if res, ok := ctx.Value(connCtx{}).(*pgxpool.Conn); ok && res != nil { 89 | res.Release() 90 | } 91 | } 92 | 93 | // txCtx key. 94 | type txCtx struct{} 95 | 96 | // connCtx key. 97 | type connCtx struct{} 98 | 99 | // conn returns a PostgreSQL transaction if one exists. 100 | // If not, returns a connection if a connection has been acquired by calling WithAcquire. 101 | // Otherwise, it returns *pgxpool.Pool which acquires the connection and closes it immediately after a SQL command is executed. 102 | func (db DB) conn(ctx context.Context) database.PGXQuerier { 103 | if tx, ok := ctx.Value(txCtx{}).(pgx.Tx); ok && tx != nil { 104 | return tx 105 | } 106 | if res, ok := ctx.Value(connCtx{}).(*pgxpool.Conn); ok && res != nil { 107 | return res 108 | } 109 | return db.pool 110 | } 111 | 112 | var _ inventory.DB = (*DB)(nil) // Check if methods expected by inventory.DB are implemented correctly. 113 | 114 | // CreateProduct creates a new product. 115 | func (db DB) CreateProduct(ctx context.Context, params inventory.CreateProductParams) error { 116 | const sql = `INSERT INTO product ("id", "name", "description", "price") VALUES ($1, $2, $3, $4);` 117 | switch _, err := db.conn(ctx).Exec(ctx, sql, params.ID, params.Name, params.Description, params.Price); { 118 | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): 119 | return err 120 | case err != nil: 121 | if sqlErr := db.productPgError(err); sqlErr != nil { 122 | return sqlErr 123 | } 124 | db.log.Error("cannot create product on database", slog.Any("error", err)) 125 | return errors.New("cannot create product on database") 126 | } 127 | return nil 128 | } 129 | 130 | func (db DB) productPgError(err error) error { 131 | var pgErr *pgconn.PgError 132 | if !errors.As(err, &pgErr) { 133 | return nil 134 | } 135 | if pgErr.Code == pgerrcode.UniqueViolation { 136 | return errors.New("product already exists") 137 | } 138 | if pgErr.Code == pgerrcode.CheckViolation { 139 | switch pgErr.ConstraintName { 140 | case "product_id_check": 141 | return errors.New("invalid product ID") 142 | case "product_name_check": 143 | return errors.New("invalid product name") 144 | case "product_price_check": 145 | return errors.New("invalid price") 146 | } 147 | } 148 | return nil 149 | } 150 | 151 | // ErrProductNotFound is returned when a product is not found. 152 | var ErrProductNotFound = errors.New("product not found") 153 | 154 | // UpdateProduct updates an existing product. 155 | func (db DB) UpdateProduct(ctx context.Context, params inventory.UpdateProductParams) error { 156 | const sql = `UPDATE "product" SET 157 | "name" = COALESCE($1, "name"), 158 | "description" = COALESCE($2, "description"), 159 | "price" = COALESCE($3, "price"), 160 | "modified_at" = now() 161 | WHERE id = $4` 162 | ct, err := db.conn(ctx).Exec(ctx, sql, 163 | params.Name, 164 | params.Description, 165 | params.Price, 166 | params.ID) 167 | if err == context.Canceled || err == context.DeadlineExceeded { 168 | return err 169 | } 170 | if err != nil { 171 | if sqlErr := db.productPgError(err); sqlErr != nil { 172 | return sqlErr 173 | } 174 | db.log.Error("cannot update product on database", slog.Any("error", err)) 175 | return errors.New("cannot update product on database") 176 | } 177 | if ct.RowsAffected() == 0 { 178 | return ErrProductNotFound 179 | } 180 | return nil 181 | } 182 | 183 | // product table. 184 | type product struct { 185 | ID string 186 | Name string 187 | Description string 188 | Price int 189 | CreatedAt time.Time 190 | ModifiedAt time.Time 191 | } 192 | 193 | func (p *product) dto() *inventory.Product { 194 | return &inventory.Product{ 195 | ID: p.ID, 196 | Name: p.Name, 197 | Description: p.Description, 198 | Price: p.Price, 199 | CreatedAt: p.CreatedAt, 200 | ModifiedAt: p.ModifiedAt, 201 | } 202 | } 203 | 204 | // GetProduct returns a product. 205 | func (db DB) GetProduct(ctx context.Context, id string) (*inventory.Product, error) { 206 | var p product 207 | // The following pgtools.Wildcard() call returns: 208 | // "id","product_id","reviewer_id","title","description","score","created_at","modified_at" 209 | sql := fmt.Sprintf(`SELECT %s FROM "product" WHERE id = $1 LIMIT 1`, pgtools.Wildcard(p)) // #nosec G201 210 | rows, err := db.conn(ctx).Query(ctx, sql, id) 211 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { 212 | return nil, err 213 | } 214 | if err == nil { 215 | p, err = pgx.CollectOneRow(rows, pgx.RowToStructByPos[product]) 216 | } 217 | if errors.Is(err, pgx.ErrNoRows) { 218 | return nil, nil 219 | } 220 | if err != nil { 221 | db.log.Error("cannot get product from database", 222 | slog.Any("id", id), 223 | slog.Any("error", err), 224 | ) 225 | return nil, errors.New("cannot get product from database") 226 | } 227 | return p.dto(), nil 228 | } 229 | 230 | // SearchProducts returns a list of products. 231 | func (db DB) SearchProducts(ctx context.Context, params inventory.SearchProductsParams) (*inventory.SearchProductsResponse, error) { 232 | var ( 233 | args = []any{"%" + params.QueryString + "%"} 234 | w = []string{"name LIKE $1"} 235 | ) 236 | 237 | if params.MinPrice != 0 { 238 | args = append(args, params.MinPrice) 239 | w = append(w, fmt.Sprintf(`"price" >= $%d`, len(args))) 240 | } 241 | if params.MaxPrice != 0 { 242 | args = append(args, params.MaxPrice) 243 | w = append(w, fmt.Sprintf(`"price" <= $%d`, len(args))) 244 | } 245 | 246 | where := strings.Join(w, " AND ") 247 | sqlTotal := fmt.Sprintf(`SELECT COUNT(*) AS total FROM "product" WHERE %s`, where) // #nosec G201 248 | resp := inventory.SearchProductsResponse{ 249 | Items: []*inventory.Product{}, 250 | } 251 | switch err := db.conn(ctx).QueryRow(ctx, sqlTotal, args...).Scan(&resp.Total); { 252 | case err == context.Canceled || err == context.DeadlineExceeded: 253 | return nil, err 254 | case err != nil: 255 | db.log.Error("cannot get product count from the database", slog.Any("error", err)) 256 | return nil, errors.New("cannot get product") 257 | } 258 | 259 | // Once the count query was made, add pagination args and query the results of the current page. 260 | sql := fmt.Sprintf(`SELECT * FROM "product" WHERE %s ORDER BY "id" DESC`, where) // #nosec G201 261 | if params.Pagination.Limit != 0 { 262 | args = append(args, params.Pagination.Limit) 263 | sql += fmt.Sprintf(` LIMIT $%d`, len(args)) 264 | } 265 | if params.Pagination.Offset != 0 { 266 | args = append(args, params.Pagination.Offset) 267 | sql += fmt.Sprintf(` OFFSET $%d`, len(args)) 268 | } 269 | 270 | rows, err := db.conn(ctx).Query(ctx, sql, args...) 271 | if err == context.Canceled || err == context.DeadlineExceeded { 272 | return nil, err 273 | } 274 | var products []product 275 | if err == nil { 276 | products, err = pgx.CollectRows(rows, pgx.RowToStructByPos[product]) 277 | } 278 | if err != nil { 279 | db.log.Error("cannot get products from the database", slog.Any("error", err)) 280 | return nil, errors.New("cannot get products") 281 | } 282 | for _, p := range products { 283 | resp.Items = append(resp.Items, p.dto()) 284 | } 285 | return &resp, nil 286 | } 287 | 288 | // DeleteProduct from the database. 289 | func (db DB) DeleteProduct(ctx context.Context, id string) error { 290 | switch _, err := db.conn(ctx).Exec(ctx, `DELETE FROM "product" WHERE "id" = $1`, id); { 291 | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): 292 | return err 293 | case err != nil: 294 | db.log.Error("cannot delete product from database", slog.Any("error", err)) 295 | return errors.New("cannot delete product from database") 296 | } 297 | return nil 298 | } 299 | 300 | // CreateProductReview for a given product. 301 | func (db DB) CreateProductReview(ctx context.Context, params inventory.CreateProductReviewDBParams) error { 302 | const sql = ` 303 | INSERT INTO review ( 304 | "id", "product_id", "reviewer_id", 305 | "title", "description", "score" 306 | ) 307 | VALUES ( 308 | $1, $2, $3, 309 | $4, $5, $6 310 | );` 311 | switch _, err := db.conn(ctx).Exec(ctx, sql, 312 | params.ID, params.ProductID, params.ReviewerID, 313 | params.Title, params.Description, params.Score); { 314 | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): 315 | return err 316 | case err != nil: 317 | if sqlErr := db.productReviewPgError(err); sqlErr != nil { 318 | return sqlErr 319 | } 320 | db.log.Error("cannot create review on database", slog.Any("error", err)) 321 | return errors.New("cannot create review on database") 322 | } 323 | return nil 324 | } 325 | 326 | func (db DB) productReviewPgError(err error) error { 327 | var pgErr *pgconn.PgError 328 | if !errors.As(err, &pgErr) { 329 | return nil 330 | } 331 | if pgErr.Code == pgerrcode.UniqueViolation { 332 | return errors.New("product review already exists") 333 | } 334 | if pgErr.Code == pgerrcode.ForeignKeyViolation && pgErr.ConstraintName == "review_product_id_fkey" { 335 | return inventory.ErrCreateReviewNoProduct 336 | } 337 | if pgErr.Code == pgerrcode.CheckViolation { 338 | switch pgErr.ConstraintName { 339 | case "review_id_check": 340 | return errors.New("invalid product review ID") 341 | case "review_title_check": 342 | return errors.New("invalid title") 343 | case "review_score_check": 344 | return errors.New("invalid score") 345 | } 346 | } 347 | return nil 348 | } 349 | 350 | // ErrReviewNotFound is returned when a review is not found. 351 | var ErrReviewNotFound = errors.New("product review not found") 352 | 353 | // UpdateProductReview for a given product. 354 | func (db DB) UpdateProductReview(ctx context.Context, params inventory.UpdateProductReviewParams) error { 355 | const sql = `UPDATE "review" SET 356 | "title" = COALESCE($1, "title"), 357 | "score" = COALESCE($2, "score"), 358 | "description" = COALESCE($3, "description"), 359 | "modified_at" = now() 360 | WHERE id = $4` 361 | 362 | switch ct, err := db.conn(ctx).Exec(ctx, sql, params.Title, params.Score, params.Description, params.ID); { 363 | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): 364 | return err 365 | case err != nil: 366 | if sqlErr := db.productReviewPgError(err); sqlErr != nil { 367 | return sqlErr 368 | } 369 | db.log.Error("cannot update review on database", slog.Any("error", err)) 370 | return errors.New("cannot update review on database") 371 | default: 372 | if ct.RowsAffected() == 0 { 373 | return ErrReviewNotFound 374 | } 375 | return nil 376 | } 377 | } 378 | 379 | // review table. 380 | type review struct { 381 | ID string 382 | ProductID string 383 | ReviewerID string 384 | Score int32 385 | Title string 386 | Description string 387 | CreatedAt time.Time 388 | ModifiedAt time.Time 389 | } 390 | 391 | func (r *review) dto() *inventory.ProductReview { 392 | return &inventory.ProductReview{ 393 | ID: r.ID, 394 | ProductID: r.ProductID, 395 | ReviewerID: r.ReviewerID, 396 | Score: r.Score, 397 | Title: r.Title, 398 | Description: r.Description, 399 | CreatedAt: r.CreatedAt, 400 | ModifiedAt: r.ModifiedAt, 401 | } 402 | } 403 | 404 | // GetProductReview gets a specific review. 405 | func (db DB) GetProductReview(ctx context.Context, id string) (*inventory.ProductReview, error) { 406 | // The following pgtools.Wildcard() call returns: 407 | // "id","product_id","reviewer_id","title","description","score","created_at","modified_at" 408 | var r review 409 | sql := fmt.Sprintf(`SELECT %s FROM "review" WHERE id = $1 LIMIT 1`, pgtools.Wildcard(r)) // #nosec G201 410 | rows, err := db.conn(ctx).Query(ctx, sql, id) 411 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { 412 | return nil, err 413 | } 414 | if err == nil { 415 | r, err = pgx.CollectOneRow(rows, pgx.RowToStructByPos[review]) 416 | } 417 | if errors.Is(err, pgx.ErrNoRows) { 418 | return nil, nil 419 | } 420 | if err != nil { 421 | db.log.Error("cannot get product review from database", 422 | slog.Any("id", id), 423 | slog.Any("error", err)) 424 | return nil, errors.New("cannot get product review from database") 425 | } 426 | return r.dto(), nil 427 | } 428 | 429 | // GetProductReviews gets reviews for a given product or from a given user. 430 | func (db DB) GetProductReviews(ctx context.Context, params inventory.ProductReviewsParams) (*inventory.ProductReviewsResponse, error) { 431 | var ( 432 | args []any 433 | where []string 434 | ) 435 | if params.ProductID != "" { 436 | args = append(args, params.ProductID) 437 | where = append(where, fmt.Sprintf(`"product_id" = $%d`, len(args))) 438 | } 439 | if params.ReviewerID != "" { 440 | args = append(args, params.ReviewerID) 441 | where = append(where, fmt.Sprintf(`"reviewer_id" = $%d`, len(args))) 442 | } 443 | sql := fmt.Sprintf(`SELECT %s FROM "review"`, pgtools.Wildcard(review{})) // #nosec G201 444 | sqlTotal := `SELECT COUNT(*) AS total FROM "review"` 445 | if len(where) > 0 { 446 | w := " WHERE " + strings.Join(where, " AND ") // #nosec G202 447 | sql += w 448 | sqlTotal += w 449 | } 450 | 451 | resp := &inventory.ProductReviewsResponse{ 452 | Reviews: []*inventory.ProductReview{}, 453 | } 454 | err := db.conn(ctx).QueryRow(ctx, sqlTotal, args...).Scan(&resp.Total) 455 | if err == context.Canceled || err == context.DeadlineExceeded { 456 | return nil, err 457 | } 458 | if err != nil { 459 | db.log.Error("cannot get reviews count from the database", slog.Any("error", err)) 460 | return nil, errors.New("cannot get reviews") 461 | } 462 | 463 | // Once the count query was made, add pagination args and query the results of the current page. 464 | sql += ` ORDER BY "created_at" DESC` 465 | if params.Pagination.Limit != 0 { 466 | args = append(args, params.Pagination.Limit) 467 | sql += fmt.Sprintf(` LIMIT $%d`, len(args)) 468 | } 469 | if params.Pagination.Offset != 0 { 470 | args = append(args, params.Pagination.Offset) 471 | sql += fmt.Sprintf(` OFFSET $%d`, len(args)) 472 | } 473 | rows, err := db.conn(ctx).Query(ctx, sql, args...) 474 | if err == context.Canceled || err == context.DeadlineExceeded { 475 | return nil, err 476 | } 477 | var reviews []review 478 | if err == nil { 479 | reviews, err = pgx.CollectRows(rows, pgx.RowToStructByPos[review]) 480 | } 481 | if err != nil { 482 | db.log.Error("cannot get reviews from database", slog.Any("error", err)) 483 | return nil, errors.New("cannot get reviews") 484 | } 485 | for _, r := range reviews { 486 | resp.Reviews = append(resp.Reviews, r.dto()) 487 | } 488 | return resp, nil 489 | } 490 | 491 | // DeleteProductReview from the database. 492 | func (db DB) DeleteProductReview(ctx context.Context, id string) error { 493 | switch _, err := db.conn(ctx).Exec(ctx, `DELETE FROM "review" WHERE id = $1`, id); { 494 | case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): 495 | return err 496 | case err != nil: 497 | db.log.Error("cannot delete review from database", 498 | slog.Any("id", id), 499 | slog.Any("error", err), 500 | ) 501 | return errors.New("cannot delete review from database") 502 | } 503 | return nil 504 | } 505 | -------------------------------------------------------------------------------- /internal/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "go.opentelemetry.io/otel/metric" 7 | "go.opentelemetry.io/otel/propagation" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | // Provider for telemetry services. 12 | type Provider struct { 13 | log *slog.Logger 14 | tracer trace.Tracer 15 | meter metric.Meter 16 | propagator propagation.TextMapPropagator 17 | } 18 | 19 | // NewProvider creates a new telemetry provider. 20 | func NewProvider(log *slog.Logger, tracer trace.Tracer, meter metric.Meter, propagator propagation.TextMapPropagator) *Provider { 21 | return &Provider{ 22 | log: log, 23 | tracer: tracer, 24 | meter: meter, 25 | propagator: propagator, 26 | } 27 | } 28 | 29 | // Logger returns the slog logger. 30 | func (p Provider) Logger() *slog.Logger { 31 | return p.log 32 | } 33 | 34 | // Tracer returns the OpenTelemetry tracer. 35 | func (p Provider) Tracer() trace.Tracer { 36 | return p.tracer 37 | } 38 | 39 | // Meter returns the OpenTelemetry meter. 40 | func (p Provider) Meter() metric.Meter { 41 | return p.meter 42 | } 43 | 44 | // Propagator returns the OpenTelemetry propagator. 45 | func (p Provider) Propagator() propagation.TextMapPropagator { 46 | return p.propagator 47 | } 48 | -------------------------------------------------------------------------------- /internal/telemetry/telemetry_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "os" 7 | "testing" 8 | 9 | "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" 10 | "go.opentelemetry.io/otel/metric" 11 | "go.opentelemetry.io/otel/propagation" 12 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 13 | tracenoop "go.opentelemetry.io/otel/trace/noop" 14 | ) 15 | 16 | func meterProvider(t testing.TB) metric.MeterProvider { 17 | metricsExp, err := stdoutmetric.New( 18 | stdoutmetric.WithEncoder(json.NewEncoder(os.Stdout)), 19 | ) 20 | if err != nil { 21 | t.Errorf("Error creating stdout exporter: %v", err) 22 | } 23 | return sdkmetric.NewMeterProvider(sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricsExp))) 24 | } 25 | 26 | func TestNewProvider(t *testing.T) { 27 | logger := slog.Default() 28 | tracer := tracenoop.NewTracerProvider() 29 | meter := meterProvider(t) 30 | propagator := propagation.NewCompositeTextMapPropagator( 31 | propagation.TraceContext{}, 32 | propagation.Baggage{}, 33 | ) 34 | 35 | // Test creating a new provider 36 | provider := NewProvider(logger, tracer.Tracer("example"), meter.Meter("example"), propagator) 37 | 38 | // Test Logger method 39 | if provider.Logger() != logger { 40 | t.Errorf("Expected Logger() to return the correct logger") 41 | } 42 | 43 | // Test Tracer method 44 | if provider.Tracer() != tracer.Tracer("example") { 45 | t.Errorf("Expected Tracer() to return the correct tracer") 46 | } 47 | 48 | // Test Meter method 49 | if provider.Meter() != meter.Meter("example") { 50 | t.Errorf("Expected Meter() to return the correct meter") 51 | } 52 | 53 | // Test Propagator method 54 | if provider.Propagator() == nil { 55 | t.Errorf("Expected Propagator() to be set correctly") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/telemetry/telemetrytest/telemetrytest.go: -------------------------------------------------------------------------------- 1 | package telemetrytest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "log/slog" 9 | 10 | "github.com/henvic/pgxtutorial/internal/telemetry" 11 | "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" 12 | metricnoop "go.opentelemetry.io/otel/metric/noop" 13 | "go.opentelemetry.io/otel/propagation" 14 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 15 | "go.opentelemetry.io/otel/sdk/resource" 16 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 17 | "go.opentelemetry.io/otel/sdk/trace/tracetest" 18 | tracenoop "go.opentelemetry.io/otel/trace/noop" 19 | ) 20 | 21 | // Discard all telemetry. 22 | func Discard() *telemetry.Provider { 23 | tracer := tracenoop.NewTracerProvider().Tracer("discard") 24 | meter := metricnoop.NewMeterProvider().Meter("discard") 25 | propagator := propagation.NewCompositeTextMapPropagator() 26 | logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) 27 | return telemetry.NewProvider(logger, tracer, meter, propagator) 28 | } 29 | 30 | // Record of the telemetry. 31 | type Memory struct { 32 | trace *tracetest.InMemoryExporter 33 | meter bytes.Buffer 34 | log bytes.Buffer 35 | mr *sdkmetric.PeriodicReader 36 | } 37 | 38 | // Reset the recorded telemetry. 39 | func (mem *Memory) Reset() { 40 | mem.trace.Reset() 41 | mem.meter.Reset() 42 | mem.log.Reset() 43 | } 44 | 45 | // Trace returns the recorded spans. 46 | func (mem *Memory) Trace() []sdktrace.ReadOnlySpan { 47 | return mem.trace.GetSpans().Snapshots() 48 | } 49 | 50 | // Meter of the telemetry. 51 | func (mem *Memory) Meter() string { 52 | if err := mem.mr.ForceFlush(context.Background()); err != nil { 53 | panic(err) 54 | } 55 | return mem.meter.String() 56 | } 57 | 58 | // Log of the telemetry. 59 | func (mem *Memory) Log() string { 60 | return mem.log.String() 61 | } 62 | 63 | // Provider for telemetrytest. 64 | func Provider() (provider *telemetry.Provider, mem *Memory) { 65 | mem = &Memory{} 66 | mem.trace = tracetest.NewInMemoryExporter() 67 | logger := slog.New(slog.NewJSONHandler(&mem.log, nil)) 68 | mt, err := stdoutmetric.New(stdoutmetric.WithEncoder(json.NewEncoder(&mem.meter))) 69 | if err != nil { 70 | panic(err) 71 | } 72 | mem.mr = sdkmetric.NewPeriodicReader(mt) 73 | tp := sdktrace.NewTracerProvider( 74 | sdktrace.WithResource(resource.Default()), 75 | sdktrace.WithSyncer(mem.trace), 76 | ) 77 | mp := sdkmetric.NewMeterProvider( 78 | sdkmetric.WithResource(resource.Default()), 79 | sdkmetric.WithReader(mem.mr), 80 | ) 81 | propagator := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) 82 | return telemetry.NewProvider(logger, tp.Tracer("tracer"), mp.Meter("meter"), propagator), mem 83 | } 84 | -------------------------------------------------------------------------------- /internal/telemetry/telemetrytest/telemetrytest_test.go: -------------------------------------------------------------------------------- 1 | package telemetrytest_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/henvic/pgxtutorial/internal/telemetry/telemetrytest" 8 | "go.opentelemetry.io/otel/propagation" 9 | ) 10 | 11 | func TestProvider(t *testing.T) { 12 | tel, mem := telemetrytest.Provider() 13 | tel.Logger().Info("an example") 14 | 15 | want := `"msg":"an example"` 16 | if got := mem.Log(); !strings.Contains(got, want) { 17 | t.Errorf("mem.Log() = %v doesn't contain %v", got, want) 18 | } 19 | 20 | _, span := tel.Tracer().Start(t.Context(), "span") 21 | span.AddEvent("an_event") 22 | span.End() 23 | spans := mem.Trace() 24 | if len(spans) != 1 { 25 | t.Fatalf("len(spans) = %v, want 1", len(spans)) 26 | } 27 | if got, want := spans[0].Name(), "span"; got != want { 28 | t.Errorf("spans[0].Name() = %v, want %v", got, want) 29 | } 30 | i, err := tel.Meter().Int64Counter("onecounter") 31 | if err != nil { 32 | t.Errorf("tel.Meter().Int64Counter() = %v, want nil", err) 33 | } 34 | i.Add(t.Context(), 63) 35 | if !strings.Contains(mem.Meter(), "onecounter") { 36 | t.Errorf("mem.Meter() = %v, want to contain %v", mem.Meter(), "onecounter") 37 | } 38 | mem.Reset() 39 | i.Add(t.Context(), 1337) 40 | if mem.Log() != "" { 41 | t.Errorf("mem.Log() = %v, want empty", mem.Log()) 42 | } 43 | if len(mem.Trace()) != 0 { 44 | t.Errorf("len(mem.Trace()) = %v, want 0", len(mem.Trace())) 45 | } 46 | if !strings.Contains(mem.Meter(), `"Value":1400`) { 47 | t.Errorf("mem.Meter() = %v, want to contain %v", mem.Meter(), `"Value":1400`) 48 | } 49 | tel.Propagator().Inject(t.Context(), propagation.HeaderCarrier{ 50 | "abc": []string{"def"}, 51 | }) 52 | if len(tel.Propagator().Fields()) != 3 { 53 | t.Errorf("len(tel.Propagator().Fields()) = %v, want 4", len(tel.Propagator().Fields())) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | _ "expvar" // #nosec G108 7 | "flag" 8 | "fmt" 9 | "log/slog" 10 | "net/http" 11 | _ "net/http/pprof" // #nosec G108 12 | "os" 13 | "os/signal" 14 | "runtime" 15 | "runtime/debug" 16 | "sync" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/felixge/fgprof" 21 | "github.com/henvic/pgxtutorial/internal/api" 22 | "github.com/henvic/pgxtutorial/internal/database" 23 | "github.com/henvic/pgxtutorial/internal/inventory" 24 | "github.com/henvic/pgxtutorial/internal/postgres" 25 | "go.opentelemetry.io/otel" 26 | "go.opentelemetry.io/otel/attribute" 27 | "go.opentelemetry.io/otel/codes" 28 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 29 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 30 | "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" 31 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 32 | "go.opentelemetry.io/otel/metric" 33 | "go.opentelemetry.io/otel/metric/noop" 34 | "go.opentelemetry.io/otel/propagation" 35 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 36 | "go.opentelemetry.io/otel/sdk/resource" 37 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 38 | semconv "go.opentelemetry.io/otel/semconv/v1.17.0" 39 | "go.opentelemetry.io/otel/trace" 40 | tracenoop "go.opentelemetry.io/otel/trace/noop" 41 | "go.uber.org/automaxprocs/maxprocs" 42 | "google.golang.org/grpc/credentials/insecure" 43 | ) 44 | 45 | var ( 46 | httpAddr = flag.String("http", "localhost:8080", "HTTP service address to listen for incoming requests on") 47 | grpcAddr = flag.String("grpc", "localhost:8082", "gRPC service address to listen for incoming requests on") 48 | probeAddr = flag.String("probe", "localhost:6060", "probe (inspection) HTTP service address") 49 | version = flag.Bool("version", false, "Print build info") 50 | 51 | buildInfo, _ = debug.ReadBuildInfo() 52 | ) 53 | 54 | // buildInfoTelemetry for OpenTelemetry. 55 | func buildInfoTelemetry() []attribute.KeyValue { 56 | attrs := []attribute.KeyValue{ 57 | semconv.ServiceName("api"), 58 | semconv.ServiceVersion("1.0.0"), 59 | attribute.Key("build.go").String(runtime.Version()), 60 | } 61 | for _, s := range buildInfo.Settings { 62 | switch s.Key { 63 | case "vcs.revision", "vcs.time": 64 | attrs = append(attrs, attribute.Key("build."+s.Key).String(s.Value)) 65 | case "vcs.modified": 66 | attrs = append(attrs, attribute.Key("build.vcs.modified").Bool(s.Value == "true")) 67 | } 68 | } 69 | return attrs 70 | } 71 | 72 | func main() { 73 | flag.Parse() 74 | if *version { 75 | fmt.Println(buildInfo) 76 | os.Exit(2) 77 | } 78 | 79 | p := program{ 80 | log: slog.Default(), 81 | } 82 | 83 | haltTelemetry, err := p.telemetry() 84 | if err != nil { 85 | p.log.Error("cannot initialize telemetry", slog.Any("error", err)) 86 | os.Exit(1) 87 | } 88 | // Setting catch-all global OpenTelemetry providers. 89 | otel.SetTracerProvider(p.tracer) 90 | otel.SetTextMapPropagator(p.propagator) 91 | otel.SetMeterProvider(p.meter) 92 | otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { 93 | p.log.Error("irremediable OpenTelemetry event", slog.Any("error", err)) 94 | })) 95 | 96 | defer func() { 97 | if err != nil { 98 | os.Exit(1) 99 | } 100 | }() 101 | defer haltTelemetry() 102 | 103 | _, span := otel.Tracer("main").Start(context.Background(), "main") 104 | defer func() { 105 | if r := recover(); r != nil { 106 | span.RecordError(fmt.Errorf("%v", r), 107 | trace.WithAttributes(attribute.String("stack_trace", string(debug.Stack())))) 108 | span.SetStatus(codes.Error, "program killed by a panic") 109 | span.End() 110 | panic(r) 111 | } 112 | 113 | if err != nil { 114 | span.RecordError(err) 115 | span.SetStatus(codes.Error, "program exited with error") 116 | } else { 117 | span.SetStatus(codes.Ok, "") 118 | } 119 | span.End() 120 | }() 121 | 122 | if err = p.run(); err != nil { 123 | p.log.Error("application terminated by error", slog.Any("error", err)) 124 | } 125 | } 126 | 127 | type program struct { 128 | log *slog.Logger 129 | tracer trace.TracerProvider 130 | propagator propagation.TextMapPropagator 131 | meter metric.MeterProvider 132 | } 133 | 134 | func (p *program) run() error { 135 | // Set GOMAXPROCS to match Linux container CPU quota on Linux. 136 | if runtime.GOOS == "linux" { 137 | if _, err := maxprocs.Set(maxprocs.Logger(p.log.Info)); err != nil { 138 | p.log.Error("cannot set GOMAXPROCS", slog.Any("error", err)) 139 | } 140 | } 141 | 142 | // Register fgprof HTTP handler, a sampling Go profiler. 143 | http.DefaultServeMux.Handle("/debug/fgprof", fgprof.Handler()) 144 | 145 | pgxLogLevel, err := database.LogLevelFromEnv() 146 | if err != nil { 147 | return fmt.Errorf("cannot get pgx logging level: %w", err) 148 | } 149 | pgPool, err := database.NewPGXPool(context.Background(), "", &database.PGXStdLogger{ 150 | Logger: p.log, 151 | }, pgxLogLevel, p.tracer) 152 | if err != nil { 153 | return fmt.Errorf("cannot create pgx pool: %w", err) 154 | } 155 | defer pgPool.Close() 156 | 157 | s := &api.Server{ 158 | Inventory: inventory.NewService(postgres.NewDB(pgPool, p.log)), 159 | Log: p.log, 160 | Tracer: p.tracer, 161 | Meter: p.meter, 162 | Propagator: p.propagator, 163 | HTTPAddress: *httpAddr, 164 | GRPCAddress: *grpcAddr, 165 | ProbeAddress: *probeAddr, 166 | } 167 | ec := make(chan error, 1) 168 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 169 | go func() { 170 | ec <- s.Run(context.Background()) 171 | }() 172 | 173 | // Waits for an internal error that shutdowns the server. 174 | // Otherwise, wait for a SIGINT or SIGTERM and tries to shutdown the server gracefully. 175 | // After a shutdown signal, HTTP requests taking longer than the specified grace period are forcibly closed. 176 | select { 177 | case err = <-ec: 178 | case <-ctx.Done(): 179 | fmt.Println() 180 | haltCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 181 | defer cancel() 182 | s.Shutdown(haltCtx) 183 | stop() 184 | err = <-ec 185 | } 186 | if err != nil { 187 | return fmt.Errorf("application terminated by error: %w", err) 188 | } 189 | return nil 190 | } 191 | 192 | // telemetry initializes OpenTelemetry tracing and metrics providers. 193 | func (p *program) telemetry() (halt func(), err error) { 194 | p.propagator = propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) 195 | var ( 196 | tr sdktrace.SpanExporter 197 | mt sdkmetric.Exporter 198 | ) 199 | 200 | // OTEL_EXPORTER can be used to configure whether to use the OpenTelemetry gRPC exporter protocol, stdout, or noop. 201 | switch exporter, ok := os.LookupEnv("OTEL_EXPORTER"); { 202 | case exporter == "stdout": 203 | // Tip: Use stdouttrace.WithPrettyPrint() to print spans in human readable format. 204 | if tr, err = stdouttrace.New(); err != nil { 205 | return nil, fmt.Errorf("stdouttrace: %w", err) 206 | } 207 | if mt, err = stdoutmetric.New(stdoutmetric.WithEncoder(json.NewEncoder(os.Stdout))); err != nil { 208 | return nil, fmt.Errorf("stdoutmetric: %w", err) 209 | } 210 | case exporter == "otlp": 211 | if tr, err = otlptracegrpc.New(context.Background(), otlptracegrpc.WithTLSCredentials(insecure.NewCredentials())); err != nil { 212 | return nil, fmt.Errorf("otlptracegrpc: %w", err) 213 | } 214 | 215 | if mt, err = otlpmetricgrpc.New(context.Background(), otlpmetricgrpc.WithTLSCredentials(insecure.NewCredentials())); err != nil { 216 | return nil, fmt.Errorf("otlpmetricgrpc: %w", err) 217 | } 218 | case ok: 219 | p.log.Warn("unknown OTEL_EXPORTER value") 220 | fallthrough 221 | default: 222 | p.tracer = tracenoop.NewTracerProvider() 223 | 224 | p.meter = noop.NewMeterProvider() 225 | return func() {}, nil 226 | } 227 | 228 | res, err := resource.New(context.Background(), 229 | resource.WithAttributes(buildInfoTelemetry()...)) 230 | if err != nil { 231 | return nil, fmt.Errorf("cannot initialize tracer resource: %w", err) 232 | } 233 | 234 | tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample()), sdktrace.WithResource(res), sdktrace.WithBatcher(tr)) 235 | mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(sdkmetric.NewPeriodicReader(mt))) 236 | p.tracer = tp 237 | p.meter = mp 238 | 239 | // The following function will be called when the graceful shutdown starts. 240 | return func() { 241 | haltCtx, cancel := context.WithTimeout(context.Background(), time.Second) 242 | defer cancel() 243 | var w sync.WaitGroup 244 | w.Add(2) 245 | go func() { 246 | defer w.Done() 247 | if err := tp.Shutdown(haltCtx); err != nil { 248 | p.log.Error("telemetry tracer shutdown", slog.Any("error", err)) 249 | } 250 | }() 251 | go func() { 252 | defer w.Done() 253 | if err := mp.Shutdown(haltCtx); err != nil { 254 | p.log.Error("telemetry meter shutdown", slog.Any("error", err)) 255 | } 256 | }() 257 | w.Wait() 258 | }, nil 259 | } 260 | -------------------------------------------------------------------------------- /migrations/001_initial_schema.sql: -------------------------------------------------------------------------------- 1 | -- Write your migrate up statements here 2 | 3 | -- product table 4 | CREATE TABLE product ( 5 | id text PRIMARY KEY CHECK (ID != '') NOT NULL, 6 | name text NOT NULL CHECK (NAME != ''), 7 | description text NOT NULL, 8 | price int NOT NULL CHECK (price >= 0), 9 | created_at timestamp with time zone NOT NULL DEFAULT now(), 10 | modified_at timestamp with time zone NOT NULL DEFAULT now() 11 | -- If you want to use a soft delete strategy, you'll need something like: 12 | -- deleted_at timestamp with time zone DEFAULT now() 13 | -- or better: a product_history table to keep track of each change here. 14 | ); 15 | 16 | COMMENT ON COLUMN product.id IS 'assume id is the barcode'; 17 | COMMENT ON COLUMN product.price IS 'price in the smaller subdivision possible (such as cents)'; 18 | CREATE INDEX product_name ON product(name text_pattern_ops); 19 | 20 | -- review table 21 | CREATE TABLE review ( 22 | id text PRIMARY KEY CHECK (id != '') NOT NULL, 23 | product_id text NOT NULL REFERENCES product(id) ON DELETE CASCADE, 24 | reviewer_id text NOT NULL, 25 | title text NOT NULL CHECK (title != ''), 26 | description text NOT NULL, 27 | score int NOT NULL CHECK (score >= 0 AND score <= 5), 28 | created_at timestamp with time zone NOT NULL DEFAULT now(), 29 | modified_at timestamp with time zone NOT NULL DEFAULT now() 30 | ); 31 | 32 | CREATE INDEX review_title ON review(title text_pattern_ops); 33 | 34 | ---- create above / drop below ---- 35 | 36 | -- Write your migrate down statements here. If this migration is irreversible 37 | -- Then delete the separator line above. 38 | DROP TABLE review; 39 | DROP TABLE product; -------------------------------------------------------------------------------- /scripts/ci-lint-fmt.sh: -------------------------------------------------------------------------------- 1 | # Adapted from @aminueza's go-github-action/fmt/fmt.sh 2 | # Reference: https://github.com/aminueza/go-github-action/blob/master/fmt/fmt.sh 3 | # Execute fmt tool, resolve and emit each unformatted file 4 | UNFORMATTED_FILES=$(go fmt $(go list ./... | grep -v /vendor/)) 5 | 6 | if [ -n "$UNFORMATTED_FILES" ]; then 7 | echo '::error::The following files are not properly formatted:' 8 | echo "$UNFORMATTED_FILES" | while read -r LINE; do 9 | FILE=$(realpath --relative-base="." "$LINE") 10 | echo "::error:: $FILE" 11 | done 12 | exit 1 13 | fi 14 | -------------------------------------------------------------------------------- /scripts/ci-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | # Static analysis scripts 6 | cd $(dirname $0)/.. 7 | 8 | source scripts/ci-lint-fmt.sh 9 | 10 | set -x 11 | go vet ./... 12 | go tool honnef.co/go/tools/cmd/staticcheck ./... 13 | go tool github.com/securego/gosec/v2/cmd/gosec -quiet -exclude-generated ./... 14 | # Run govulncheck only informationally for the time being. 15 | go tool golang.org/x/vuln/cmd/govulncheck ./... || true 16 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Modified version of chef-runner/script/coverage 4 | # Copyright 2004 Mathias Lafeldt 5 | # Apache License 2.0 6 | # Source: https://github.com/mlafeldt/chef-runner/blob/v0.7.0/script/coverage 7 | 8 | # Generate test coverage statistics for Go packages. 9 | # 10 | # Works around the fact that `go test -coverprofile` currently does not work 11 | # with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 12 | # 13 | # Usage: script/coverage [--html|--coveralls] 14 | # 15 | # --html Additionally create HTML report and open it in browser 16 | # --coveralls Push coverage statistics to coveralls.io 17 | # 18 | # Changes: directories ending in .go used to fail 19 | 20 | set -e 21 | 22 | workdir=.cover 23 | profile="$workdir/cover.out" 24 | mode=count 25 | 26 | generate_cover_data() { 27 | rm -rf "$workdir" 28 | mkdir "$workdir" 29 | 30 | for pkg in "$@"; do 31 | f="$workdir/$(echo $pkg | tr / -).cover" 32 | go test -covermode="$mode" -coverprofile="$f" "$pkg/" 33 | done 34 | 35 | echo "mode: $mode" >"$profile" 36 | grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" 37 | } 38 | 39 | show_cover_report() { 40 | go tool cover -${1}="$profile" 41 | } 42 | 43 | push_to_coveralls() { 44 | echo "Pushing coverage statistics to coveralls.io" 45 | goveralls -coverprofile="$profile" 46 | } 47 | 48 | generate_cover_data $(go list ./...) 49 | show_cover_report func 50 | case "$1" in 51 | "") 52 | ;; 53 | --html) 54 | show_cover_report html ;; 55 | --coveralls) 56 | push_to_coveralls ;; 57 | *) 58 | echo >&2 "error: invalid option: $1"; exit 1 ;; 59 | esac 60 | --------------------------------------------------------------------------------