├── .air.toml ├── .devcontainer ├── .dockerignore ├── Dockerfile ├── README.md ├── devcontainer.json └── docker-compose.yml ├── .dockerignore ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature_request.md │ └── help.md ├── dependabot.yml ├── labels.yml └── workflows │ ├── ci.yml │ ├── labels.yml │ ├── markdown-skip.yml │ └── markdown.yml ├── .gitignore ├── .golangci.yml ├── .markdownlint.json ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── app │ └── main.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── config │ └── settings │ │ ├── database.go │ │ ├── health.go │ │ ├── helpers.go │ │ ├── http.go │ │ ├── jsondatabase.go │ │ ├── log.go │ │ ├── memorydatabase.go │ │ ├── metrics.go │ │ ├── postgresdatabase.go │ │ ├── settings.go │ │ └── websocket.go ├── data │ ├── errors │ │ └── errors.go │ ├── interface.go │ ├── json │ │ ├── database.go │ │ ├── database_test.go │ │ └── users.go │ ├── memory │ │ ├── database.go │ │ └── users.go │ └── psql │ │ ├── database.go │ │ ├── interfaces.go │ │ └── users.go ├── health │ ├── client.go │ ├── handler.go │ ├── health.go │ └── interfaces.go ├── metrics │ ├── constants.go │ ├── helpers.go │ ├── interfaces.go │ └── metrics.go ├── models │ ├── build.go │ └── models.go ├── processor │ ├── interfaces.go │ ├── processor.go │ └── users.go └── server │ ├── contenttype │ ├── apicheck.go │ ├── apicheck_test.go │ ├── contenttype.go │ ├── contenttypes.go │ └── errors.go │ ├── decodejson │ ├── decodejson.go │ └── decodejson_test.go │ ├── fileserver │ └── fileserver.go │ ├── httperr │ ├── httperr.go │ ├── respond.go │ ├── respond_test.go │ └── responder.go │ ├── interfaces.go │ ├── middlewares │ ├── cors │ │ ├── cors.go │ │ ├── cors_test.go │ │ ├── middleware.go │ │ └── middleware_test.go │ ├── log │ │ ├── clientip.go │ │ ├── clientip_test.go │ │ ├── interfaces.go │ │ ├── log.go │ │ └── writer.go │ └── metrics │ │ ├── interfaces.go │ │ ├── metrics.go │ │ ├── metrics_test.go │ │ ├── mocks_generate_test.go │ │ ├── mocks_test.go │ │ └── writer.go │ ├── mocks_generate_test.go │ ├── mocks_test.go │ ├── router.go │ ├── router_test.go │ ├── routes │ ├── build │ │ ├── getinfo.go │ │ ├── getinfo_test.go │ │ ├── handler.go │ │ ├── handler_test.go │ │ ├── interfaces.go │ │ ├── mocks_generate_test.go │ │ ├── mocks_test.go │ │ └── options.go │ └── users │ │ ├── createuser.go │ │ ├── getuser.go │ │ ├── handler.go │ │ ├── interfaces.go │ │ └── options.go │ └── websocket │ └── handler.go ├── postgres ├── Dockerfile └── schema.sql ├── title.svg └── todo.md /.air.toml: -------------------------------------------------------------------------------- 1 | # Documentation at https://github.com/cosmtrek/air/blob/master/air_example.toml 2 | root = "." 3 | 4 | [build] 5 | args_bin = ["--log-level", "debug"] 6 | bin = "./main" 7 | cmd = "go build -o ./main ./cmd/app/main.go" 8 | delay = 500 9 | exclude_dir = [] 10 | exclude_regex = ["_test\\.go"] 11 | exclude_unchanged = true 12 | follow_symlink = false 13 | full_bin = "" 14 | include_dir = [] 15 | include_ext = ["go"] 16 | include_file = [] # broken on air v1.51.0 17 | kill_delay = "2s" 18 | log = "" 19 | poll = false 20 | poll_interval = 500 21 | post_cmd = [] 22 | pre_cmd = [] 23 | rerun = false 24 | rerun_delay = 0 25 | send_interrupt = true 26 | stop_on_error = true 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true 45 | -------------------------------------------------------------------------------- /.devcontainer/.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | devcontainer.json 3 | docker-compose.yml 4 | Dockerfile 5 | README.md 6 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM qmcgaw/godevcontainer 2 | RUN go install github.com/cosmtrek/air@latest -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | # Development container 2 | 3 | Development container that can be used with VSCode. 4 | 5 | It works on Linux, Windows and OSX. 6 | 7 | ## Requirements 8 | 9 | - [VS code](https://code.visualstudio.com/download) installed 10 | - [VS code dev containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) installed 11 | - [Docker](https://www.docker.com/products/docker-desktop) installed and running 12 | - [Docker Compose](https://docs.docker.com/compose/install/) installed 13 | 14 | ## Setup 15 | 16 | 1. Create the following files and directory on your host if you don't have them: 17 | 18 | ```sh 19 | touch ~/.gitconfig ~/.zsh_history 20 | mkdir -p ~/.ssh 21 | ``` 22 | 23 | 1. **For Docker on OSX**: ensure the project directory and your home directory `~` are accessible by Docker. 24 | 1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P). 25 | 1. Select `Dev Containers: Open Folder in Container...` and choose the project directory. 26 | 27 | ## Customizations 28 | 29 | For customizations to take effect, you should "rebuild and reopen": 30 | 31 | 1. Open the command palette in Visual Studio Code (CTRL+SHIFT+P) 32 | 2. Select `Dev Containers: Rebuild Container` 33 | 34 | Customizations available are notably: 35 | 36 | - Extend the Docker image in [Dockerfile](Dockerfile). For example add curl to it: 37 | 38 | ```Dockerfile 39 | FROM qmcgaw/godevcontainer 40 | RUN apk add curl 41 | ``` 42 | 43 | - Changes to VSCode **settings** and **extensions** in [devcontainer.json](devcontainer.json). 44 | - Change the entrypoint script by adding a bind mount in [devcontainer.json](devcontainer.json) of a shell script to `/root/.welcome.sh` to replace the [current welcome script](https://github.com/qdm12/godevcontainer/blob/master/shell/.welcome.sh). For example: 45 | 46 | ```json 47 | // Welcome script 48 | { 49 | "source": "./.welcome.sh", 50 | "target": "/root/.welcome.sh", 51 | "type": "bind" 52 | }, 53 | ``` 54 | 55 | - Change the `vscode` service container configuration either in [docker-compose.yml](docker-compose.yml) or in [devcontainer.json](devcontainer.json). 56 | - Add other services in [docker-compose.yml](docker-compose.yml) to run together with the development VSCode service container. For example to add a test database: 57 | 58 | ```yml 59 | database: 60 | image: postgres 61 | restart: always 62 | environment: 63 | POSTGRES_PASSWORD: password 64 | ``` 65 | 66 | - More customizations available are documented in the [devcontainer.json reference](https://containers.dev/implementors/json_reference/). 67 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://containers.dev/implementors/json_reference/ 3 | // User defined settings 4 | "containerEnv": { 5 | "TZ": "" 6 | }, 7 | // Fixed settings 8 | "name": "go-template-dev", 9 | "postCreateCommand": "~/.windows.sh && go mod download && go mod tidy", 10 | "dockerComposeFile": "docker-compose.yml", 11 | "overrideCommand": true, 12 | "service": "vscode", 13 | "workspaceFolder": "/workspace", 14 | "mounts": [ 15 | // Source code 16 | { 17 | "source": "../", 18 | "target": "/workspace", 19 | "type": "bind" 20 | }, 21 | // Zsh commands history persistence 22 | { 23 | "source": "${localEnv:HOME}/.zsh_history", 24 | "target": "/root/.zsh_history", 25 | "type": "bind" 26 | }, 27 | // Git configuration file 28 | { 29 | "source": "${localEnv:HOME}/.gitconfig", 30 | "target": "/root/.gitconfig", 31 | "type": "bind" 32 | }, 33 | // SSH directory for Linux, OSX and WSL 34 | // On Linux and OSX, a symlink /mnt/ssh <-> ~/.ssh is 35 | // created in the container. On Windows, files are copied 36 | // from /mnt/ssh to ~/.ssh to fix permissions. 37 | { 38 | "source": "${localEnv:HOME}/.ssh", 39 | "target": "/mnt/ssh", 40 | "type": "bind" 41 | }, 42 | // Docker socket to access the host Docker server 43 | { 44 | "source": "/var/run/docker.sock", 45 | "target": "/var/run/docker.sock", 46 | "type": "bind" 47 | } 48 | ], 49 | "customizations": { 50 | "vscode": { 51 | "extensions": [ 52 | "golang.go", 53 | "eamodio.gitlens", // IDE Git information 54 | "davidanson.vscode-markdownlint", 55 | "ms-azuretools.vscode-docker", // Docker integration and linting 56 | "shardulm94.trailing-spaces", // Show trailing spaces 57 | "Gruntfuggly.todo-tree", // Highlights TODO comments 58 | "bierner.emojisense", // Emoji sense for markdown 59 | "stkb.rewrap", // rewrap comments after n characters on one line 60 | "vscode-icons-team.vscode-icons", // Better file extension icons 61 | "github.vscode-pull-request-github", // Github interaction 62 | "redhat.vscode-yaml", // Kubernetes, Drone syntax highlighting 63 | "bajdzis.vscode-database", // Supports connections to mysql or postgres, over SSL, socked 64 | "IBM.output-colorizer", // Colorize your output/test logs 65 | "github.copilot" // AI code completion 66 | // "mohsen1.prettify-json", // Prettify JSON data 67 | // "zxh404.vscode-proto3", // Supports Proto syntax 68 | // "jrebocho.vscode-random", // Generates random values 69 | // "alefragnani.Bookmarks", // Manage bookmarks 70 | // "quicktype.quicktype", // Paste JSON as code 71 | // "spikespaz.vscode-smoothtype", // smooth cursor animation 72 | ], 73 | "settings": { 74 | "files.eol": "\n", 75 | "editor.formatOnSave": true, 76 | "go.buildTags": "", 77 | "go.toolsEnvVars": { 78 | "CGO_ENABLED": "0" 79 | }, 80 | "go.useLanguageServer": true, 81 | "go.testEnvVars": { 82 | "CGO_ENABLED": "1" 83 | }, 84 | "go.testFlags": [ 85 | "-v", 86 | "-race" 87 | ], 88 | "go.testTimeout": "10s", 89 | "go.coverOnSingleTest": true, 90 | "go.coverOnSingleTestFile": true, 91 | "go.coverOnTestPackage": true, 92 | "go.lintTool": "golangci-lint", 93 | "go.lintOnSave": "package", 94 | "[go]": { 95 | "editor.codeActionsOnSave": { 96 | "source.organizeImports": "always" 97 | } 98 | }, 99 | "gopls": { 100 | "usePlaceholders": false, 101 | "staticcheck": true, 102 | "formatting.gofumpt": true 103 | }, 104 | "remote.extensionKind": { 105 | "ms-azuretools.vscode-docker": "workspace" 106 | } 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | vscode: 3 | build: . 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .github 3 | .vscode 4 | .dockerignore 5 | .gitignore 6 | docker-compose.yml 7 | Dockerfile 8 | LICENSE 9 | README.md 10 | title.svg 11 | .air.toml 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @qdm12 -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [open source license of this project](../LICENSE). 4 | 5 | ## Submitting a pull request 6 | 7 | 1. [Fork](https://github.com/qdm12/go-template/fork) and clone the repository 8 | 1. Create a new branch `git checkout -b my-branch-name` 9 | 1. Modify the code 10 | 1. Ensure the docker build succeeds `docker build .` 11 | 1. Commit your modifications 12 | 1. Push to your fork and [submit a pull request](https://github.com/qdm12/go-template/compare) 13 | 14 | ## Resources 15 | 16 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 17 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [qdm12] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report a bug 4 | title: 'Bug: FILL THIS TEXT!' 5 | 6 | --- 7 | 8 | **Host OS** (approximate answer is fine too): Ubuntu 18 9 | 10 | **Is this urgent?**: No 11 | 12 | **What is the version of the program** (See the line at the top of your logs) 13 | 14 | ``` 15 | Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c) 16 | ``` 17 | 18 | **What's the problem** 🤔 19 | 20 | That feature doesn't work 21 | 22 | **Share your logs...** 23 | 24 | ...*careful to remove credentials etc.* 25 | 26 | ```log 27 | 28 | PASTE YOUR LOGS 29 | IN THERE 30 | 31 | ``` 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature to add to this project 4 | title: 'Feature request: FILL THIS TEXT!' 5 | 6 | --- 7 | 8 | **What's the feature?** 🧐 9 | 10 | - Support this new feature because that and that 11 | 12 | **Optional extra information** 🚀 13 | 14 | - I tried `this` and it doesn't work 15 | - That [url](https://github.com/qdm12/go-template) is interesting 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help 3 | about: Ask for help 4 | title: 'Help: FILL THIS TEXT!' 5 | 6 | --- 7 | 8 | **Host OS** (approximate answer is fine too): Ubuntu 18 9 | 10 | **Is this urgent?**: No 11 | 12 | **What is the version of the program** (See the line at the top of your logs) 13 | 14 | ``` 15 | Running version latest built on 2020-03-13T01:30:06Z (commit d0f678c) 16 | ``` 17 | 18 | **What's the problem** 🤔 19 | 20 | That feature doesn't work 21 | 22 | **Share your logs...** 23 | 24 | ...*careful to remove i.e. token information with PIA port forwarding* 25 | 26 | ```log 27 | 28 | PASTE YOUR LOGS 29 | IN THERE 30 | 31 | ``` 32 | 33 | **What are you using to run the program?**: 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: docker 9 | directory: / 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: gomod 13 | directory: / 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Temporary status 2 | - name: "Status: 🗯️ Waiting for feedback" 3 | color: "f7d692" 4 | - name: "Status: 🔴 Blocked" 5 | color: "f7d692" 6 | description: "Blocked by another issue or pull request" 7 | - name: "Status: 🔒 After next release" 8 | color: "f7d692" 9 | description: "Will be done after the next release" 10 | 11 | # Final status 12 | - name: "Closed: ⚰️ Inactive" 13 | color: "959a9c" 14 | description: "No answer was received for weeks" 15 | - name: "Closed: 👥 Duplicate" 16 | color: "959a9c" 17 | description: "Issue duplicates an existing issue" 18 | - name: "Closed: 🗑️ Bad issue" 19 | color: "959a9c" 20 | 21 | # Priority 22 | - name: "Priority: 🚨 Urgent" 23 | color: "03adfc" 24 | - name: "Priority: 💤 Low priority" 25 | color: "03adfc" 26 | 27 | # Complexity 28 | - name: "Complexity: ☣️ Hard to do" 29 | color: "ff9efc" 30 | - name: "Complexity: 🟩 Easy to do" 31 | color: "ff9efc" 32 | 33 | # Generic categories 34 | - name: "Category: Config problem 📝" 35 | color: "ffc7ea" 36 | - name: "Category: Documentation ✒️" 37 | color: "ffc7ea" 38 | - name: "Category: Maintenance ⛓️" 39 | description: "Anything related to code or other maintenance" 40 | color: "ffc7ea" 41 | - name: "Category: Good idea 🎯" 42 | description: "This is a good idea, judged by the maintainers" 43 | color: "ffc7ea" 44 | - name: "Category: Motivated! 🙌" 45 | description: "Your pumpness makes me pumped! The issue or PR shows great motivation!" 46 | color: "ffc7ea" 47 | - name: "Category: Label missing ❗" 48 | color: "ffc7ea" 49 | 50 | # Project specific categories 51 | - name: "Category: Healthcheck 🩺" 52 | color: "ffc7ea" 53 | - name: "Category: Foolproof settings 👼" 54 | color: "ffc7ea" 55 | - name: "Category: HTTP server 🌐" 56 | color: "ffc7ea" 57 | - name: "Category: Metrics 📊" 58 | color: "ffc7ea" 59 | - name: "Category: Database 🗃️" 60 | color: "ffc7ea" 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | release: 4 | types: 5 | - published 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/workflows/ci.yml 11 | - cmd/** 12 | - internal/** 13 | - pkg/** 14 | - .dockerignore 15 | - .golangci.yml 16 | - Dockerfile 17 | - go.mod 18 | - go.sum 19 | pull_request: 20 | paths: 21 | - .github/workflows/ci.yml 22 | - cmd/** 23 | - internal/** 24 | - pkg/** 25 | - .dockerignore 26 | - .golangci.yml 27 | - Dockerfile 28 | - go.mod 29 | - go.sum 30 | 31 | jobs: 32 | verify: 33 | runs-on: ubuntu-latest 34 | permissions: 35 | actions: read 36 | contents: read 37 | env: 38 | DOCKER_BUILDKIT: "1" 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: reviewdog/action-misspell@v1 43 | with: 44 | locale: "US" 45 | level: error 46 | exclude: | 47 | *.md 48 | 49 | - name: Linting 50 | run: docker build --target lint . 51 | 52 | - name: Mocks check 53 | run: docker build --target mocks . 54 | 55 | - name: Build test image 56 | run: docker build --target test -t test-container . 57 | 58 | - name: Run tests in test container 59 | run: | 60 | touch coverage.txt 61 | docker run --rm \ 62 | -v "$(pwd)/coverage.txt:/tmp/gobuild/coverage.txt" \ 63 | test-container 64 | 65 | # We run this here to use the caching of the previous steps 66 | - name: Build final image 67 | run: docker build . 68 | 69 | codeql: 70 | runs-on: ubuntu-latest 71 | permissions: 72 | actions: read 73 | contents: read 74 | security-events: write 75 | steps: 76 | - uses: actions/checkout@v4 77 | - uses: actions/setup-go@v5 78 | with: 79 | go-version-file: go.mod 80 | - uses: github/codeql-action/init@v3 81 | with: 82 | languages: go 83 | - uses: github/codeql-action/autobuild@v3 84 | - uses: github/codeql-action/analyze@v3 85 | 86 | publish: 87 | if: | 88 | github.repository == 'qdm12/go-template' && 89 | ( 90 | github.event_name == 'push' || 91 | github.event_name == 'release' || 92 | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') 93 | ) 94 | needs: [verify, codeql] 95 | permissions: 96 | actions: read 97 | contents: read 98 | packages: write 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v4 102 | 103 | # extract metadata (tags, labels) for Docker 104 | # https://github.com/docker/metadata-action 105 | - name: Extract Docker metadata 106 | id: meta 107 | uses: docker/metadata-action@v4 108 | with: 109 | flavor: | 110 | latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 111 | images: | 112 | ghcr.io/qdm12/go-template-docker 113 | qmcgaw/go-template-docker 114 | tags: | 115 | type=ref,event=pr 116 | type=semver,pattern=v{{major}}.{{minor}}.{{patch}} 117 | type=semver,pattern=v{{major}}.{{minor}} 118 | type=semver,pattern=v{{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} 119 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 120 | 121 | - uses: docker/setup-qemu-action@v2 122 | - uses: docker/setup-buildx-action@v2 123 | 124 | - uses: docker/login-action@v2 125 | with: 126 | username: qmcgaw 127 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 128 | 129 | - uses: docker/login-action@v2 130 | with: 131 | registry: ghcr.io 132 | username: ${{ github.repository_owner }} 133 | password: ${{ github.token }} 134 | 135 | - name: Short commit 136 | id: shortcommit 137 | run: echo "::set-output name=value::$(git rev-parse --short HEAD)" 138 | 139 | - name: Build and push final image 140 | uses: docker/build-push-action@v4 141 | with: 142 | platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v6,linux/arm/v7,linux/s390x,linux/ppc64le,linux/riscv64 143 | labels: ${{ steps.meta.outputs.labels }} 144 | build-args: | 145 | CREATED=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} 146 | COMMIT=${{ steps.shortcommit.outputs.value }} 147 | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 148 | tags: ${{ steps.meta.outputs.tags }} 149 | push: true 150 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: labels 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - .github/labels.yml 7 | - .github/workflows/labels.yml 8 | jobs: 9 | labeler: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: crazy-max/ghaction-github-labeler@v4 14 | with: 15 | yaml-file: .github/labels.yml 16 | -------------------------------------------------------------------------------- /.github/workflows/markdown-skip.yml: -------------------------------------------------------------------------------- 1 | name: Markdown 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - "**.md" 8 | - .github/workflows/markdown.yml 9 | pull_request: 10 | paths-ignore: 11 | - "**.md" 12 | - .github/workflows/markdown.yml 13 | 14 | jobs: 15 | markdown: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: read 19 | steps: 20 | - name: No trigger path triggered for required markdown workflow. 21 | run: exit 0 22 | -------------------------------------------------------------------------------- /.github/workflows/markdown.yml: -------------------------------------------------------------------------------- 1 | name: Markdown 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "**.md" 8 | - .github/workflows/markdown.yml 9 | pull_request: 10 | paths: 11 | - "**.md" 12 | - .github/workflows/markdown.yml 13 | 14 | jobs: 15 | markdown: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | actions: read 19 | contents: read 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: DavidAnson/markdownlint-cli2-action@v14 24 | with: 25 | globs: "**.md" 26 | config: .markdownlint.json 27 | 28 | - uses: reviewdog/action-misspell@v1 29 | with: 30 | locale: "US" 31 | level: error 32 | pattern: | 33 | *.md 34 | 35 | - uses: gaurav-nelson/github-action-markdown-link-check@v1 36 | with: 37 | use-quiet-mode: yes 38 | 39 | - uses: peter-evans/dockerhub-description@v3 40 | if: github.repository == 'qdm12/go-template' && github.event_name == 'push' 41 | with: 42 | username: qmcgaw 43 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 44 | repository: qmcgaw/go-template-docker 45 | short-description: SHORT_DESCRIPTION 46 | readme-filepath: README.md 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | postgres_user 2 | postgres_password -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | misspell: 3 | locale: US 4 | 5 | issues: 6 | exclude-dirs: 7 | - .devcontainer 8 | - .github 9 | - postgres 10 | exclude-rules: 11 | - path: _test\.go 12 | linters: 13 | - dupl 14 | - err113 15 | - path: cmd/ 16 | text: commit is a global variable 17 | linters: 18 | - gochecknoglobals 19 | - path: cmd/ 20 | text: buildDate is a global variable 21 | linters: 22 | - gochecknoglobals 23 | - linters: 24 | - ireturn 25 | text: ".+returns interface \\(github\\.com\\/prometheus\\/client_golang\\/prometheus\\.[a-zA-Z]+\\)$" 26 | 27 | linters: 28 | enable: 29 | - asasalint 30 | - asciicheck 31 | - bidichk 32 | - bodyclose 33 | - canonicalheader 34 | - containedctx 35 | - contextcheck 36 | - copyloopvar 37 | - cyclop 38 | - decorder 39 | - dogsled 40 | - dupl 41 | - dupword 42 | - durationcheck 43 | - err113 44 | - errchkjson 45 | - errname 46 | - errorlint 47 | - exhaustive 48 | - fatcontext 49 | - forcetypeassert 50 | - gci 51 | - gocheckcompilerdirectives 52 | - gochecknoglobals 53 | - gochecknoinits 54 | - gochecksumtype 55 | - gocognit 56 | - goconst 57 | - gocritic 58 | - gocyclo 59 | - godot 60 | - gofumpt 61 | - goheader 62 | - goimports 63 | - gomoddirectives 64 | - goprintffuncname 65 | - gosec 66 | - gosmopolitan 67 | - grouper 68 | - iface 69 | - importas 70 | - inamedparam 71 | - interfacebloat 72 | - intrange 73 | - ireturn 74 | - lll 75 | - maintidx 76 | - makezero 77 | - mirror 78 | - misspell 79 | - mnd 80 | - musttag 81 | - nakedret 82 | - nestif 83 | - nilerr 84 | - nilnil 85 | - noctx 86 | - nolintlint 87 | - nosprintfhostport 88 | - paralleltest 89 | - perfsprint 90 | - prealloc 91 | - predeclared 92 | - promlinter 93 | - protogetter 94 | - reassign 95 | - recvcheck 96 | - revive 97 | - rowserrcheck 98 | - sloglint 99 | - sqlclosecheck 100 | - tagalign 101 | - tenv 102 | - thelper 103 | - tparallel 104 | - unconvert 105 | - unparam 106 | - usestdlibvars 107 | - wastedassign 108 | - whitespace 109 | - zerologlint 110 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Sets linux/amd64 in case it's not injected by older Docker versions 2 | ARG BUILDPLATFORM=linux/amd64 3 | 4 | ARG ALPINE_VERSION=3.20 5 | ARG GO_VERSION=1.23 6 | ARG XCPUTRANSLATE_VERSION=v0.6.0 7 | ARG GOLANGCI_LINT_VERSION=v1.63.4 8 | ARG MOCKGEN_VERSION=v1.6.0 9 | 10 | FROM --platform=${BUILDPLATFORM} qmcgaw/xcputranslate:${XCPUTRANSLATE_VERSION} AS xcputranslate 11 | FROM --platform=${BUILDPLATFORM} qmcgaw/binpot:golangci-lint-${GOLANGCI_LINT_VERSION} AS golangci-lint 12 | FROM --platform=${BUILDPLATFORM} qmcgaw/binpot:mockgen-${MOCKGEN_VERSION} AS mockgen 13 | 14 | FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS base 15 | ENV CGO_ENABLED=0 16 | WORKDIR /tmp/gobuild 17 | # Note: findutils needed to have xargs support `-d` flag for mocks stage. 18 | RUN apk --update add git g++ findutils 19 | COPY --from=xcputranslate /xcputranslate /usr/local/bin/xcputranslate 20 | COPY --from=golangci-lint /bin /go/bin/golangci-lint 21 | COPY --from=mockgen /bin /go/bin/mockgen 22 | COPY go.mod go.sum ./ 23 | RUN go mod download 24 | COPY cmd/ ./cmd/ 25 | COPY internal/ ./internal/ 26 | 27 | FROM base AS test 28 | # Note on the go race detector: 29 | # - we set CGO_ENABLED=1 to have it enabled 30 | # - we installed g++ in the base stage to support the race detector 31 | ENV CGO_ENABLED=1 32 | ENTRYPOINT go test -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic ./... 33 | 34 | FROM base AS lint 35 | COPY .golangci.yml ./ 36 | RUN golangci-lint run --timeout=10m 37 | 38 | FROM --platform=${BUILDPLATFORM} base AS mocks 39 | RUN git init && \ 40 | git config user.email ci@localhost && \ 41 | git config user.name ci && \ 42 | git config core.fileMode false && \ 43 | git add -A && \ 44 | git commit -m "snapshot" && \ 45 | grep -lr -E '^// Code generated by MockGen\. DO NOT EDIT\.$' . | xargs -r -d '\n' rm && \ 46 | go generate -run "mockgen" ./... && \ 47 | git diff --exit-code && \ 48 | rm -rf .git/ 49 | 50 | FROM base AS build 51 | ARG TARGETPLATFORM 52 | ARG VERSION=unknown 53 | ARG CREATED="an unknown date" 54 | ARG COMMIT=unknown 55 | RUN GOARCH="$(xcputranslate translate -targetplatform=${TARGETPLATFORM} -field arch)" \ 56 | GOARM="$(xcputranslate translate -targetplatform=${TARGETPLATFORM} -field arm)" \ 57 | go build -trimpath -ldflags="-s -w \ 58 | -X 'main.version=$VERSION' \ 59 | -X 'main.buildDate=$CREATED' \ 60 | -X 'main.commit=$COMMIT' \ 61 | " -o app cmd/app/main.go 62 | 63 | FROM scratch 64 | USER 1000 65 | ENTRYPOINT ["/app"] 66 | EXPOSE 8000/tcp 67 | HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=2 CMD ["/app","healthcheck"] 68 | ENV HTTP_SERVER_ADDRESS=:8000 \ 69 | HTTP_SERVER_ROOT_URL=/ \ 70 | HTTP_SERVER_LOG_REQUESTS=on \ 71 | HTTP_SERVER_ALLOWED_ORIGINS= \ 72 | HTTP_SERVER_ALLOWED_HEADERS= \ 73 | METRICS_SERVER_ADDRESS=:9090 \ 74 | LOG_LEVEL=info \ 75 | STORE_TYPE=memory \ 76 | STORE_JSON_FILEPATH=data.json \ 77 | STORE_POSTGRES_ADDRESS=psql:5432 \ 78 | STORE_POSTGRES_USER=postgres \ 79 | STORE_POSTGRES_PASSWORD=postgres \ 80 | STORE_POSTGRES_DATABASE=database \ 81 | HEALTH_SERVER_ADDRESS=127.0.0.1:9999 \ 82 | TZ=America/Montreal 83 | COPY --chown=1000 postgres/schema.sql /schema.sql 84 | ARG VERSION=unknown 85 | ARG CREATED="an unknown date" 86 | ARG COMMIT=unknown 87 | LABEL \ 88 | org.opencontainers.image.authors="quentin.mcgaw@gmail.com" \ 89 | org.opencontainers.image.version=$VERSION \ 90 | org.opencontainers.image.created=$CREATED \ 91 | org.opencontainers.image.revision=$COMMIT \ 92 | org.opencontainers.image.url="https://github.com/qdm12/go-template" \ 93 | org.opencontainers.image.documentation="https://github.com/qdm12/go-template/blob/main/README.md" \ 94 | org.opencontainers.image.source="https://github.com/qdm12/go-template" \ 95 | org.opencontainers.image.title="go-template" \ 96 | org.opencontainers.image.description="SHORT_DESCRIPTION" 97 | COPY --from=build --chown=1000 /tmp/gobuild/app /app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Quentin McGaw 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 | # go-template 2 | 3 | SHORT_DESCRIPTION 4 | 5 | ![Title](https://raw.githubusercontent.com/qdm12/go-template/main/title.svg) 6 | 7 | [![Build status](https://github.com/qdm12/go-template/actions/workflows/ci.yml/badge.svg)](https://github.com/qdm12/go-template/actions/workflows/ci.yml) 8 | 9 | [![dockeri.co](https://dockeri.co/image/qmcgaw/go-template-docker)](https://hub.docker.com/r/qmcgaw/go-template-docker) 10 | 11 | ![Last release](https://img.shields.io/github/release/qdm12/go-template?label=Last%20release) 12 | ![Last Docker tag](https://img.shields.io/docker/v/qmcgaw/go-template-docker?sort=semver&label=Last%20Docker%20tag) 13 | [![Last release size](https://img.shields.io/docker/image-size/qmcgaw/go-template-docker?sort=semver&label=Last%20released%20image)](https://hub.docker.com/r/qmcgaw/go-template-docker/tags?page=1&ordering=last_updated) 14 | ![GitHub last release date](https://img.shields.io/github/release-date/qdm12/go-template?label=Last%20release%20date) 15 | ![Commits since release](https://img.shields.io/github/commits-since/qdm12/go-template/latest?sort=semver) 16 | 17 | [![Latest size](https://img.shields.io/docker/image-size/qmcgaw/go-template-docker/latest?label=Latest%20image)](https://hub.docker.com/r/qmcgaw/go-template-docker/tags) 18 | 19 | [![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/go-template.svg)](https://github.com/qdm12/go-template/commits/main) 20 | [![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/go-template.svg)](https://github.com/qdm12/go-template/graphs/contributors) 21 | [![GitHub closed PRs](https://img.shields.io/github/issues-pr-closed/qdm12/go-template.svg)](https://github.com/qdm12/go-template/pulls?q=is%3Apr+is%3Aclosed) 22 | [![GitHub issues](https://img.shields.io/github/issues/qdm12/go-template.svg)](https://github.com/qdm12/go-template/issues) 23 | [![GitHub closed issues](https://img.shields.io/github/issues-closed/qdm12/go-template.svg)](https://github.com/qdm12/go-template/issues?q=is%3Aissue+is%3Aclosed) 24 | 25 | [![Lines of code](https://img.shields.io/tokei/lines/github/qdm12/go-template)](https://github.com/qdm12/go-template) 26 | ![Code size](https://img.shields.io/github/languages/code-size/qdm12/go-template) 27 | ![GitHub repo size](https://img.shields.io/github/repo-size/qdm12/go-template) 28 | ![Go version](https://img.shields.io/github/go-mod/go-version/qdm12/go-template) 29 | 30 | [![MIT](https://img.shields.io/github/license/qdm12/go-template)](https://github.com/qdm12/go-template/main/LICENSE) 31 | ![Visitors count](https://visitor-badge.laobi.icu/badge?page_id=go-template.readme) 32 | 33 | ## Features 34 | 35 | - Compatible with `amd64`, `386`, `arm64`, `arm32v7`, `arm32v6`, `ppc64le`, `s390x` and `riscv64` CPU architectures 36 | - [Docker image tags and sizes](https://hub.docker.com/r/qmcgaw/go-template-docker/tags) 37 | 38 | ## Setup 39 | 40 | 1. Use the following command: 41 | 42 | ```sh 43 | docker run -d qmcgaw/go-template-docker 44 | ``` 45 | 46 | You can also use [docker-compose.yml](https://github.com/qdm12/go-template/blob/main/docker-compose.yml) with: 47 | 48 | ```sh 49 | docker-compose up -d 50 | ``` 51 | 52 | 1. You can update the image with `docker pull qmcgaw/go-template-docker:latest` or use one of the [tags available](https://hub.docker.com/r/qmcgaw/go-template-docker/tags) 53 | 54 | ### Environment variables 55 | 56 | | Environment variable | Default | Possible values | Description | 57 | | --- | --- | --- | --- | 58 | | `HTTP_SERVER_ADDRESS` | `:8000` | Valid address | HTTP server listening address | 59 | | `HTTP_SERVER_ROOT_URL` | `/` | URL path | HTTP server root URL | 60 | | `HTTP_SERVER_LOG_REQUESTS` | `on` | `on` or `off` | Log requests and responses information | 61 | | `HTTP_SERVER_ALLOWED_ORIGINS` | | CSV of addresses | Comma separated list of addresses to allow for CORS | 62 | | `HTTP_SERVER_ALLOWED_HEADERS` | | CSV of HTTP header keys | Comma separated list of header keys to allow for CORS | 63 | | `METRICS_SERVER_ADDRESS` | `:9090` | Valid address | Prometheus HTTP server listening address | 64 | | `LOG_LEVEL` | `info` | `debug`, `info`, `warning`, `error` | Logging level | 65 | | `STORE_TYPE` | `memory` | `memory`, `json` or `postgres` | Data store type | 66 | | `STORE_JSON_FILEPATH` | `data.json` | Valid filepath | JSON file to use if `STORE_TYPE=json` | 67 | | `STORE_POSTGRES_ADDRESS` | `psql:5432` | Valid address | Postgres database address if `STORE_TYPE=postgres` | 68 | | `STORE_POSTGRES_USER` | `postgres` | | Postgres database user if `STORE_TYPE=postgres` | 69 | | `STORE_POSTGRES_PASSWORD` | `postgres` | | Postgres database password if `STORE_TYPE=postgres` | 70 | | `STORE_POSTGRES_DATABASE` | `database` | | Postgres database name if `STORE_TYPE=postgres` | 71 | | `HEALTH_SERVER_ADDRESS` | `127.0.0.1:9999` | Valid address | Health server listening address | 72 | | `TZ` | `America/Montreal` | *string* | Timezone | 73 | 74 | ## Development 75 | 76 | You can setup your development environment with a [Docker development container](.devcontainer) or locally: 77 | 78 | 1. Install [Go](https://golang.org/dl/), [Docker](https://www.docker.com/products/docker-desktop) and [Git](https://git-scm.com/downloads) 79 | 1. Install Go dependencies with 80 | 81 | ```sh 82 | go mod download 83 | ``` 84 | 85 | 1. Install [golangci-lint](https://github.com/golangci/golangci-lint#install) 86 | 1. You might want to use an editor such as [Visual Studio Code](https://code.visualstudio.com/download) with the [Go extension](https://code.visualstudio.com/docs/languages/go). 87 | 88 | Commands available are: 89 | 90 | ```sh 91 | # Build the binary 92 | go build cmd/app/main.go 93 | # Test the code 94 | go test ./... 95 | # Lint the code 96 | golangci-lint run 97 | # Build the Docker image 98 | docker build -t qmcgaw/go-template-docker . 99 | ``` 100 | 101 | See [Contributing](https://github.com/qdm12/go-template/main/.github/CONTRIBUTING.md) for more information on how to contribute to this repository. 102 | 103 | ## TODOs 104 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | "time" 12 | _ "time/tzdata" 13 | 14 | _ "github.com/breml/rootcerts" 15 | _ "github.com/lib/pq" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | "github.com/qdm12/go-template/internal/config/settings" 18 | "github.com/qdm12/go-template/internal/data" 19 | "github.com/qdm12/go-template/internal/health" 20 | "github.com/qdm12/go-template/internal/metrics" 21 | "github.com/qdm12/go-template/internal/models" 22 | "github.com/qdm12/go-template/internal/processor" 23 | "github.com/qdm12/go-template/internal/server" 24 | "github.com/qdm12/go-template/internal/server/websocket" 25 | "github.com/qdm12/goservices" 26 | "github.com/qdm12/goservices/hooks" 27 | "github.com/qdm12/goservices/httpserver" 28 | "github.com/qdm12/gosettings/reader" 29 | "github.com/qdm12/gosettings/reader/sources/env" 30 | "github.com/qdm12/gosplash" 31 | "github.com/qdm12/log" 32 | ) 33 | 34 | var ( 35 | // Values set by the build system. 36 | version = "unknown" 37 | commit = "unknown" 38 | buildDate = "an unknown date" 39 | ) 40 | 41 | func main() { 42 | buildInfo := models.BuildInformation{ 43 | Version: version, 44 | Commit: commit, 45 | BuildDate: buildDate, 46 | } 47 | 48 | background := context.Background() 49 | signalCh := make(chan os.Signal, 1) 50 | signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) 51 | ctx, cancel := context.WithCancel(background) 52 | 53 | args := os.Args 54 | 55 | logger := log.New() 56 | 57 | reader := reader.New(reader.Settings{ 58 | Sources: []reader.Source{env.New(env.Settings{})}, 59 | HandleDeprecatedKey: func(source string, deprecatedKey string, currentKey string) { 60 | logger.Warnf("Deprecated %s %q, please use %q instead", source, deprecatedKey, currentKey) 61 | }, 62 | }) 63 | 64 | errorCh := make(chan error) 65 | go func() { 66 | errorCh <- _main(ctx, buildInfo, args, logger, reader) 67 | }() 68 | 69 | // Wait for OS signal or run error 70 | var runError error 71 | select { 72 | case receivedSignal := <-signalCh: 73 | signal.Stop(signalCh) 74 | fmt.Println("") 75 | logger.Warn("Caught OS signal " + receivedSignal.String() + ", shutting down") 76 | cancel() 77 | case runError = <-errorCh: 78 | close(errorCh) 79 | if runError == nil { // expected exit such as healthcheck 80 | os.Exit(0) 81 | } 82 | logger.Error(runError.Error()) 83 | cancel() 84 | } 85 | 86 | // Shutdown timed sequence, and force exit on second OS signal 87 | const shutdownGracePeriod = 5 * time.Second 88 | timer := time.NewTimer(shutdownGracePeriod) 89 | select { 90 | case shutdownErr := <-errorCh: 91 | timer.Stop() 92 | if shutdownErr != nil { 93 | logger.Warnf("Shutdown failed: %s", shutdownErr) 94 | os.Exit(1) 95 | } 96 | 97 | logger.Info("Shutdown successful") 98 | if runError != nil { 99 | os.Exit(1) 100 | } 101 | os.Exit(0) 102 | case <-timer.C: 103 | logger.Warn("Shutdown timed out") 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | //nolint:cyclop 109 | func _main(ctx context.Context, buildInfo models.BuildInformation, 110 | args []string, logger log.LoggerInterface, configReader *reader.Reader, 111 | ) error { 112 | ctx, cancel := context.WithCancel(ctx) 113 | defer cancel() 114 | if health.IsClientMode(args) { 115 | // Running the program in a separate instance through the Docker 116 | // built-in healthcheck, in an ephemeral fashion to query the 117 | // long running instance of the program about its status 118 | var healthConfig settings.Health 119 | healthConfig.Read(configReader) 120 | healthConfig.SetDefaults() 121 | err := healthConfig.Validate() 122 | if err != nil { 123 | return fmt.Errorf("health configuration is invalid: %w", err) 124 | } 125 | 126 | client := health.NewClient() 127 | // TODO write listening address to file for the healthcheck to read 128 | // since the user can pass '' to listen on any available port. 129 | return client.Query(ctx, healthConfig.Address) 130 | } 131 | 132 | announcementExpiration, err := time.Parse("2006-01-02", "2021-07-14") 133 | if err != nil { 134 | return err 135 | } 136 | splashLines := gosplash.MakeLines(gosplash.Settings{ 137 | User: "qdm12", 138 | Repository: "go-template", 139 | Authors: []string{"github.com/qdm12"}, 140 | Emails: []string{"quentin.mcgaw@gmail.com"}, 141 | Version: buildInfo.Version, 142 | Commit: buildInfo.Commit, 143 | BuildDate: buildInfo.BuildDate, 144 | Announcement: "", 145 | AnnounceExp: announcementExpiration, 146 | PaypalUser: "qmcgaw", 147 | GithubSponsor: "qdm12", 148 | }) 149 | fmt.Println(strings.Join(splashLines, "\n")) 150 | 151 | var config settings.Settings 152 | err = config.Read(configReader) 153 | if err != nil { 154 | return fmt.Errorf("reading configuration: %w", err) 155 | } 156 | config.SetDefaults() 157 | err = config.Validate() 158 | if err != nil { 159 | return fmt.Errorf("configuration is invalid: %w", err) 160 | } 161 | 162 | logLevel, _ := log.ParseLevel(config.Log.Level) // level string already validated 163 | logger.Patch(log.SetLevel(logLevel)) 164 | 165 | logger.Info(config.String()) 166 | 167 | db, err := setupDatabase(config.Database, logger) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | proc := processor.NewProcessor(db) 173 | 174 | metricsServerSettings := httpserver.Settings{ 175 | Name: ptrTo("metrics"), 176 | Handler: promhttp.Handler(), 177 | Address: &config.Metrics.Address, 178 | Logger: logger.New(log.SetComponent("metrics server")), 179 | } 180 | metricsServer, err := httpserver.New(metricsServerSettings) 181 | if err != nil { 182 | return fmt.Errorf("creating metrics server: %w", err) 183 | } 184 | const registerMetrics = true 185 | metrics, err := metrics.New(registerMetrics) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | serverLogger := logger.New(log.SetComponent("http server")) 191 | serverSettings := httpserver.Settings{ 192 | Name: ptrTo("main"), 193 | Handler: server.NewRouter(config.HTTP, serverLogger, metrics, buildInfo, proc), 194 | Address: ptrTo(*config.HTTP.Address), 195 | Logger: serverLogger, 196 | } 197 | mainServer, err := httpserver.New(serverSettings) 198 | if err != nil { 199 | return fmt.Errorf("creating main server: %w", err) 200 | } 201 | 202 | websocketServerLogger := logger.New(log.SetComponent("websocket server")) 203 | websocketServerSettings := httpserver.Settings{ 204 | Name: ptrTo("websocket"), 205 | Handler: websocket.New(), 206 | Address: config.HTTP.Websocket.Address, 207 | Logger: websocketServerLogger, 208 | } 209 | websocketServer, err := httpserver.New(websocketServerSettings) 210 | if err != nil { 211 | return fmt.Errorf("creating websocket server: %w", err) 212 | } 213 | 214 | heathcheckLogger := logger.New(log.SetComponent("healthcheck")) 215 | healthcheck := func() error { return nil } 216 | healthServerHandler := health.NewHandler(heathcheckLogger, healthcheck) 217 | healthServerSettings := httpserver.Settings{ 218 | Name: ptrTo("health"), 219 | Handler: healthServerHandler, 220 | Address: &config.Health.Address, 221 | Logger: heathcheckLogger, 222 | } 223 | healthServer, err := httpserver.New(healthServerSettings) 224 | if err != nil { 225 | return fmt.Errorf("creating health server: %w", err) 226 | } 227 | 228 | servicesLogger := logger.New(log.SetComponent("services")) 229 | sequenceSettings := goservices.SequenceSettings{ 230 | ServicesStart: []goservices.Service{db, metricsServer, healthServer, websocketServer, mainServer}, 231 | ServicesStop: []goservices.Service{mainServer, websocketServer, db, healthServer, metricsServer}, 232 | Hooks: hooks.NewWithLog(servicesLogger), 233 | } 234 | services, err := goservices.NewSequence(sequenceSettings) 235 | if err != nil { 236 | return fmt.Errorf("creating sequence of services: %w", err) 237 | } 238 | 239 | runError, err := services.Start(ctx) 240 | if err != nil { 241 | return fmt.Errorf("starting services: %w", err) 242 | } 243 | 244 | select { 245 | case <-ctx.Done(): 246 | err = services.Stop() 247 | if err != nil { 248 | return fmt.Errorf("stopping services: %w", err) 249 | } 250 | return nil 251 | case err = <-runError: 252 | return fmt.Errorf("one service crashed, all services stopped: %w", err) 253 | } 254 | } 255 | 256 | var errDatabaseTypeUnknown = errors.New("database type is unknown") 257 | 258 | type Database interface { 259 | String() string 260 | Start(ctx context.Context) (runError <-chan error, err error) 261 | Stop() (err error) 262 | CreateUser(ctx context.Context, user models.User) (err error) 263 | GetUserByID(ctx context.Context, id uint64) (user models.User, err error) 264 | } 265 | 266 | func setupDatabase(databaseSettings settings.Database, logger log.LeveledLogger) ( //nolint:ireturn 267 | db Database, err error, 268 | ) { 269 | switch *databaseSettings.Type { 270 | case settings.MemoryStoreType: 271 | return data.NewMemory() 272 | case settings.JSONStoreType: 273 | return data.NewJSON(databaseSettings.JSON.Filepath) 274 | case settings.PostgresStoreType: 275 | return data.NewPostgres(databaseSettings.Postgres, logger) 276 | default: 277 | return nil, fmt.Errorf("%w: %s", errDatabaseTypeUnknown, *databaseSettings.Type) 278 | } 279 | } 280 | 281 | func ptrTo[T any](x T) *T { return &x } 282 | 283 | type ConfigSource interface { 284 | Read() (settings settings.Settings, err error) 285 | ReadHealth() (health settings.Health) 286 | String() string 287 | } 288 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | go-template-docker: 4 | build: . 5 | image: qmcgaw/go-template-docker 6 | container_name: go-template-docker 7 | ports: 8 | - 8000:8000/tcp 9 | environment: 10 | - HTTP_SERVER_ADDRESS=:8000 11 | - HTTP_SERVER_ROOT_URL=/ 12 | - HTTP_SERVER_LOG_REQUESTS=on 13 | - HTTP_SERVER_ALLOWED_ORIGINS= 14 | - HTTP_SERVER_ALLOWED_HEADERS= 15 | - METRICS_SERVER_ADDRESS=:9090 16 | - LOG_LEVEL=info 17 | - STORE_TYPE=memory 18 | - STORE_JSON_FILEPATH=data.json 19 | - STORE_POSTGRES_ADDRESS=psql:5432 20 | - STORE_POSTGRES_USER=postgres 21 | - STORE_POSTGRES_PASSWORD=postgres 22 | - STORE_POSTGRES_DATABASE=database 23 | - HEALTH_SERVER_ADDRESS=127.0.0.1:9999 24 | - TZ=America/Montreal 25 | secrets: 26 | - postgres_user 27 | - postgres_password 28 | restart: always 29 | 30 | postgres: 31 | image: postgres:14-alpine 32 | volumes: 33 | - go-template-docker:/var/lib/postgresql/data 34 | environment: 35 | POSTGRES_DB: go-template-docker 36 | POSTGRES_USER_FILE: /run/secrets/postgres_user 37 | POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password 38 | secrets: 39 | - postgres_user 40 | - postgres_password 41 | 42 | secrets: 43 | postgres_user: 44 | file: ./postgres_user 45 | postgres_password: 46 | file: ./postgres_password 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/qdm12/go-template 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/breml/rootcerts v0.2.16 7 | github.com/go-chi/chi/v5 v5.0.12 8 | github.com/golang/mock v1.6.0 9 | github.com/lib/pq v1.10.9 10 | github.com/prometheus/client_golang v1.19.0 11 | github.com/qdm12/goservices v0.1.0 12 | github.com/qdm12/gosettings v0.4.1 13 | github.com/qdm12/gosplash v0.1.0 14 | github.com/qdm12/gotree v0.3.0 15 | github.com/qdm12/log v0.1.0 16 | github.com/stretchr/testify v1.9.0 17 | golang.org/x/net v0.22.0 18 | ) 19 | 20 | require ( 21 | github.com/beorn7/perks v1.0.1 // indirect 22 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/fatih/color v1.16.0 // indirect 25 | github.com/kr/text v0.2.0 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | github.com/prometheus/client_model v0.6.0 // indirect 30 | github.com/prometheus/common v0.51.1 // indirect 31 | github.com/prometheus/procfs v0.13.0 // indirect 32 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect 33 | golang.org/x/sys v0.18.0 // indirect 34 | google.golang.org/protobuf v1.33.0 // indirect 35 | gopkg.in/yaml.v3 v3.0.1 // indirect 36 | kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 // indirect 37 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/breml/rootcerts v0.2.16 h1:yN1TGvicfHx8dKz3OQRIrx/5nE/iN3XT1ibqGbd6urc= 4 | github.com/breml/rootcerts v0.2.16/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 12 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 13 | github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= 14 | github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 15 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 16 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 20 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 24 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 33 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 34 | github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= 35 | github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= 36 | github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw= 37 | github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= 38 | github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= 39 | github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= 40 | github.com/qdm12/goservices v0.1.0 h1:9sODefm/yuIGS7ynCkEnNlMTAYn9GzPhtcK4F69JWvc= 41 | github.com/qdm12/goservices v0.1.0/go.mod h1:/JOFsAnHFiSjyoXxa5FlfX903h20K5u/3rLzCjYVMck= 42 | github.com/qdm12/gosettings v0.4.1 h1:c7+14jO1Y2kFXBCUfS2+QE2NgwTKfzcdJzGEFRItCI8= 43 | github.com/qdm12/gosettings v0.4.1/go.mod h1:uItKwGXibJp2pQ0am6MBKilpjfvYTGiH+zXHd10jFj8= 44 | github.com/qdm12/gosplash v0.1.0 h1:Sfl+zIjFZFP7b0iqf2l5UkmEY97XBnaKkH3FNY6Gf7g= 45 | github.com/qdm12/gosplash v0.1.0/go.mod h1:+A3fWW4/rUeDXhY3ieBzwghKdnIPFJgD8K3qQkenJlw= 46 | github.com/qdm12/gotree v0.3.0 h1:Q9f4C571EFK7ZEsPkEL2oGZX7I+ZhVxhh1ZSydW+5yI= 47 | github.com/qdm12/gotree v0.3.0/go.mod h1:iz06uXmRR4Aq9v6tX7mosXStO/yGHxRA1hbyD0UVeYw= 48 | github.com/qdm12/log v0.1.0 h1:jYBd/xscHYpblzZAd2kjZp2YmuYHjAAfbTViJWxoPTw= 49 | github.com/qdm12/log v0.1.0/go.mod h1:Vchi5M8uBvHfPNIblN4mjXn/oSbiWguQIbsgF1zdQPI= 50 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 51 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 52 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 53 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 54 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 55 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 56 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 58 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 59 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= 60 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= 61 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 62 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 63 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 64 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 65 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 66 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 67 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 69 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 70 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 73 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 74 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 77 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 78 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 79 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 80 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 81 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 82 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 83 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 84 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 86 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 87 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 88 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 91 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 94 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | kernel.org/pub/linux/libs/security/libcap/cap v1.2.69 h1:N0m3tKYbkRMmDobh/47ngz+AWeV7PcfXMDi8xu3Vrag= 96 | kernel.org/pub/linux/libs/security/libcap/cap v1.2.69/go.mod h1:Tk5Ip2TuxaWGpccL7//rAsLRH6RQ/jfqTGxuN/+i/FQ= 97 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 h1:IdrOs1ZgwGw5CI+BH6GgVVlOt+LAXoPyh7enr8lfaXs= 98 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.69/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 99 | -------------------------------------------------------------------------------- /internal/config/settings/database.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gotree" 10 | ) 11 | 12 | const ( 13 | MemoryStoreType = "memory" 14 | JSONStoreType = "json" 15 | PostgresStoreType = "postgres" 16 | ) 17 | 18 | type Database struct { 19 | Type *string 20 | Memory MemoryDatabase 21 | JSON JSONDatabase 22 | Postgres PostgresDatabase 23 | } 24 | 25 | func (d *Database) setDefaults() { 26 | d.Type = ptrTo(MemoryStoreType) 27 | d.Memory.setDefaults() 28 | d.JSON.setDefaults() 29 | d.Postgres.setDefaults() 30 | } 31 | 32 | var ErrDatabaseTypeUnknown = errors.New("database type is unknown") 33 | 34 | func (d *Database) validate() (err error) { 35 | switch *d.Type { 36 | case MemoryStoreType: 37 | err = d.Memory.validate() 38 | if err != nil { 39 | return fmt.Errorf("memory database: %w", err) 40 | } 41 | case JSONStoreType: 42 | err = d.JSON.validate() 43 | if err != nil { 44 | return fmt.Errorf("json database: %w", err) 45 | } 46 | case PostgresStoreType: 47 | err = d.Postgres.validate() 48 | if err != nil { 49 | return fmt.Errorf("postgres database: %w", err) 50 | } 51 | default: 52 | return fmt.Errorf("%w: %s", ErrDatabaseTypeUnknown, *d.Type) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (d *Database) toLinesNode() (node *gotree.Node) { 59 | node = gotree.New("Database settings:") 60 | node.Appendf("Type: %s", *d.Type) 61 | switch *d.Type { 62 | case MemoryStoreType: 63 | node.AppendNode(d.Memory.toLinesNode()) 64 | case JSONStoreType: 65 | node.AppendNode(d.JSON.toLinesNode()) 66 | case PostgresStoreType: 67 | node.AppendNode(d.Postgres.toLinesNode()) 68 | } 69 | return node 70 | } 71 | 72 | func (d *Database) copy() (copied Database) { 73 | return Database{ 74 | Type: gosettings.CopyPointer(d.Type), 75 | Memory: d.Memory.copy(), 76 | JSON: d.JSON.copy(), 77 | Postgres: d.Postgres.copy(), 78 | } 79 | } 80 | 81 | func (d *Database) overrideWith(other Database) { 82 | d.Type = gosettings.OverrideWithPointer(d.Type, other.Type) 83 | d.Memory.overrideWith(other.Memory) 84 | d.JSON.overrideWith(other.JSON) 85 | d.Postgres.overrideWith(other.Postgres) 86 | } 87 | 88 | func (d *Database) read(r *reader.Reader) { 89 | d.Type = r.Get("STORE_TYPE") 90 | d.Memory.read(r) 91 | d.JSON.read(r) 92 | d.Postgres.read(r) 93 | } 94 | -------------------------------------------------------------------------------- /internal/config/settings/health.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gosettings/validate" 10 | "github.com/qdm12/gotree" 11 | ) 12 | 13 | type Health struct { 14 | Address string 15 | } 16 | 17 | func (h *Health) SetDefaults() { 18 | h.Address = "127.0.0.1:9999" 19 | } 20 | 21 | func (h *Health) Validate() (err error) { 22 | err = validate.ListeningAddress(h.Address, os.Geteuid()) 23 | if err != nil { 24 | return fmt.Errorf("listening address: %w", err) 25 | } 26 | return nil 27 | } 28 | 29 | func (h *Health) toLinesNode() (node *gotree.Node) { 30 | node = gotree.New("Health settings:") 31 | node.Appendf("Server listening address: %s", h.Address) 32 | return node 33 | } 34 | 35 | func (h *Health) copy() (copied Health) { 36 | return Health{ 37 | Address: h.Address, 38 | } 39 | } 40 | 41 | func (h *Health) overrideWith(other Health) { 42 | h.Address = gosettings.OverrideWithComparable(h.Address, other.Address) 43 | } 44 | 45 | func (h *Health) Read(r *reader.Reader) { 46 | h.Address = r.String("HEALTH_ADDRESS") 47 | } 48 | -------------------------------------------------------------------------------- /internal/config/settings/helpers.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | func ptrTo[T any](value T) *T { return &value } 4 | 5 | func boolPtrToYesNo(b *bool) string { 6 | if *b { 7 | return "yes" 8 | } 9 | return "no" 10 | } 11 | 12 | func obfuscatePassword(password string) (obfuscated string) { 13 | if password == "" { 14 | return "" 15 | } 16 | return "[set]" 17 | } 18 | -------------------------------------------------------------------------------- /internal/config/settings/http.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gosettings/validate" 10 | "github.com/qdm12/gotree" 11 | ) 12 | 13 | type HTTP struct { 14 | Address *string 15 | RootURL *string 16 | LogRequests *bool 17 | AllowedOrigins []string 18 | AllowedHeaders []string 19 | Websocket Websocket 20 | } 21 | 22 | func (h *HTTP) setDefaults() { 23 | h.Address = gosettings.DefaultPointer(h.Address, ":8000") 24 | h.RootURL = gosettings.DefaultPointer(h.RootURL, "") 25 | h.LogRequests = gosettings.DefaultPointer(h.LogRequests, true) 26 | h.AllowedOrigins = gosettings.DefaultSlice(h.AllowedOrigins, []string{}) 27 | h.AllowedHeaders = gosettings.DefaultSlice(h.AllowedHeaders, []string{}) 28 | h.Websocket.setDefaults() 29 | } 30 | 31 | func (h *HTTP) validate() (err error) { 32 | err = validate.ListeningAddress(*h.Address, os.Geteuid()) 33 | if err != nil { 34 | return fmt.Errorf("listening address: %w", err) 35 | } 36 | 37 | err = h.Websocket.validate() 38 | if err != nil { 39 | return fmt.Errorf("websocket: %w", err) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (h *HTTP) toLinesNode() (node *gotree.Node) { 46 | node = gotree.New("HTTP server settings:") 47 | node.Appendf("Server listening address: %s", *h.Address) 48 | node.Appendf("Root URL: %s", *h.RootURL) 49 | node.Appendf("Log requests: %s", boolPtrToYesNo(h.LogRequests)) 50 | 51 | allowedOriginsNode := gotree.New("Allowed origins:") 52 | for _, allowedOrigin := range h.AllowedOrigins { 53 | allowedOriginsNode.Append(allowedOrigin) 54 | } 55 | node.AppendNode(allowedOriginsNode) 56 | 57 | allowedHeadersNode := gotree.New("Allowed headers:") 58 | for _, allowedHeader := range h.AllowedHeaders { 59 | allowedHeadersNode.Append(allowedHeader) 60 | } 61 | node.AppendNode(allowedHeadersNode) 62 | 63 | node.AppendNode(h.Websocket.toLinesNode()) 64 | 65 | return node 66 | } 67 | 68 | func (h *HTTP) copy() (copied HTTP) { 69 | return HTTP{ 70 | Address: gosettings.CopyPointer(h.Address), 71 | RootURL: gosettings.CopyPointer(h.RootURL), 72 | LogRequests: gosettings.CopyPointer(h.LogRequests), 73 | AllowedOrigins: gosettings.CopySlice(h.AllowedOrigins), 74 | AllowedHeaders: gosettings.CopySlice(h.AllowedHeaders), 75 | Websocket: h.Websocket.copy(), 76 | } 77 | } 78 | 79 | func (h *HTTP) overrideWith(other HTTP) { 80 | h.Address = gosettings.OverrideWithPointer(h.Address, other.Address) 81 | h.RootURL = gosettings.OverrideWithPointer(h.RootURL, other.RootURL) 82 | h.LogRequests = gosettings.OverrideWithPointer(h.LogRequests, other.LogRequests) 83 | h.AllowedOrigins = gosettings.OverrideWithSlice(h.AllowedOrigins, other.AllowedOrigins) 84 | h.AllowedHeaders = gosettings.OverrideWithSlice(h.AllowedHeaders, other.AllowedHeaders) 85 | h.Websocket.overrideWith(other.Websocket) 86 | } 87 | 88 | func (h *HTTP) read(r *reader.Reader) (err error) { 89 | h.Address = r.Get("HTTP_SERVER_ADDRESS") 90 | h.RootURL = r.Get("HTTP_SERVER_ROOT_URL") 91 | h.LogRequests, err = r.BoolPtr("HTTP_SERVER_LOG_REQUESTS") 92 | if err != nil { 93 | return fmt.Errorf("environment variable HTTP_SERVER_LOG_REQUESTS: %w", err) 94 | } 95 | h.AllowedOrigins = r.CSV("HTTP_SERVER_ALLOWED_ORIGINS") 96 | h.AllowedHeaders = r.CSV("HTTP_SERVER_ALLOWED_HEADERS") 97 | h.Websocket.read(r) 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/config/settings/jsondatabase.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/qdm12/gosettings" 9 | "github.com/qdm12/gosettings/reader" 10 | "github.com/qdm12/gotree" 11 | ) 12 | 13 | type JSONDatabase struct { 14 | Filepath string 15 | } 16 | 17 | func (j *JSONDatabase) setDefaults() { 18 | j.Filepath = gosettings.DefaultComparable(j.Filepath, "data.json") 19 | } 20 | 21 | var ErrJSONFilepathIsDirectory = errors.New("JSON filepath is a directory") 22 | 23 | func (j *JSONDatabase) validate() (err error) { 24 | stats, err := os.Stat(j.Filepath) 25 | if err != nil { 26 | return fmt.Errorf("file path: %w", err) 27 | } else if stats.IsDir() { 28 | return fmt.Errorf("%w: %s", ErrJSONFilepathIsDirectory, j.Filepath) 29 | } 30 | return nil 31 | } 32 | 33 | func (j *JSONDatabase) toLinesNode() (node *gotree.Node) { 34 | node = gotree.New("JSON database settings:") 35 | node.Appendf("File path: %s", j.Filepath) 36 | return node 37 | } 38 | 39 | func (j *JSONDatabase) copy() (copied JSONDatabase) { 40 | return JSONDatabase{ 41 | Filepath: j.Filepath, 42 | } 43 | } 44 | 45 | func (j *JSONDatabase) overrideWith(other JSONDatabase) { 46 | j.Filepath = gosettings.OverrideWithComparable(j.Filepath, other.Filepath) 47 | } 48 | 49 | func (j *JSONDatabase) read(r *reader.Reader) { 50 | j.Filepath = r.String("JSON_FILEPATH") 51 | } 52 | -------------------------------------------------------------------------------- /internal/config/settings/log.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gotree" 10 | "github.com/qdm12/log" 11 | ) 12 | 13 | type Log struct { 14 | Level string 15 | } 16 | 17 | func (l *Log) setDefaults() { 18 | l.Level = gosettings.DefaultComparable(l.Level, log.LevelInfo.String()) 19 | } 20 | 21 | var ErrLogLevelUnknown = errors.New("log level is unknown") 22 | 23 | func (l *Log) validate() (err error) { 24 | _, err = log.ParseLevel(l.Level) 25 | if err != nil { 26 | return fmt.Errorf("log level: %w", err) 27 | } 28 | return nil 29 | } 30 | 31 | func (l *Log) toLinesNode() (node *gotree.Node) { 32 | node = gotree.New("Log settings:") 33 | node.Appendf("Level: %s", l.Level) 34 | return node 35 | } 36 | 37 | func (l *Log) copy() (copied Log) { 38 | return Log{ 39 | Level: l.Level, 40 | } 41 | } 42 | 43 | func (l *Log) overrideWith(other Log) { 44 | l.Level = gosettings.OverrideWithComparable(l.Level, other.Level) 45 | } 46 | 47 | func (l *Log) read(r *reader.Reader) { 48 | l.Level = r.String("LOG_LEVEL") 49 | } 50 | -------------------------------------------------------------------------------- /internal/config/settings/memorydatabase.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/qdm12/gosettings/reader" 5 | "github.com/qdm12/gotree" 6 | ) 7 | 8 | type MemoryDatabase struct{} 9 | 10 | func (m *MemoryDatabase) setDefaults() {} 11 | 12 | func (m *MemoryDatabase) validate() (err error) { return nil } 13 | 14 | func (m *MemoryDatabase) toLinesNode() (node *gotree.Node) { 15 | return nil 16 | } 17 | 18 | func (m *MemoryDatabase) copy() (copied MemoryDatabase) { 19 | return MemoryDatabase{} 20 | } 21 | 22 | func (m *MemoryDatabase) overrideWith(MemoryDatabase) {} 23 | 24 | func (m *MemoryDatabase) read(_ *reader.Reader) {} 25 | -------------------------------------------------------------------------------- /internal/config/settings/metrics.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gosettings/validate" 10 | "github.com/qdm12/gotree" 11 | ) 12 | 13 | type Metrics struct { 14 | Address string 15 | } 16 | 17 | func (m *Metrics) setDefaults() { 18 | m.Address = gosettings.DefaultComparable(m.Address, ":9090") 19 | } 20 | 21 | func (m *Metrics) validate() (err error) { 22 | err = validate.ListeningAddress(m.Address, os.Geteuid()) 23 | if err != nil { 24 | return fmt.Errorf("listening address: %w", err) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (m *Metrics) toLinesNode() (node *gotree.Node) { 31 | node = gotree.New("Metrics settings:") 32 | node.Appendf("Server listening address: %s", m.Address) 33 | return node 34 | } 35 | 36 | func (m *Metrics) copy() (copied Metrics) { 37 | return Metrics{ 38 | Address: m.Address, 39 | } 40 | } 41 | 42 | func (m *Metrics) overrideWith(other Metrics) { 43 | m.Address = gosettings.OverrideWithComparable(m.Address, other.Address) 44 | } 45 | 46 | func (m *Metrics) read(r *reader.Reader) { 47 | m.Address = r.String("METRICS_SERVER_ADDRESS") 48 | } 49 | -------------------------------------------------------------------------------- /internal/config/settings/postgresdatabase.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/qdm12/gosettings" 5 | "github.com/qdm12/gosettings/reader" 6 | "github.com/qdm12/gotree" 7 | ) 8 | 9 | type PostgresDatabase struct { 10 | Address string 11 | User string 12 | Password string 13 | Database string 14 | } 15 | 16 | func (p *PostgresDatabase) setDefaults() { 17 | p.Address = gosettings.DefaultComparable(p.Address, "psql:5432") 18 | p.User = gosettings.DefaultComparable(p.User, "postgres") 19 | p.Password = gosettings.DefaultComparable(p.Password, "postgres") 20 | p.Database = gosettings.DefaultComparable(p.Database, "postgres") 21 | } 22 | 23 | func (p *PostgresDatabase) validate() (err error) { 24 | return nil 25 | } 26 | 27 | func (p *PostgresDatabase) toLinesNode() (node *gotree.Node) { 28 | node = gotree.New("Postgres database settings:") 29 | node.Appendf("Connection address: %s", p.Address) 30 | node.Appendf("User: %s", p.User) 31 | node.Appendf("Password: %s", obfuscatePassword(p.Password)) 32 | node.Appendf("Database name: %s", p.Database) 33 | return node 34 | } 35 | 36 | func (p *PostgresDatabase) copy() (copied PostgresDatabase) { 37 | return PostgresDatabase{ 38 | Address: p.Address, 39 | User: p.User, 40 | Password: p.Password, 41 | Database: p.Database, 42 | } 43 | } 44 | 45 | func (p *PostgresDatabase) overrideWith(other PostgresDatabase) { 46 | p.Address = gosettings.OverrideWithComparable(p.Address, other.Address) 47 | p.User = gosettings.OverrideWithComparable(p.User, other.User) 48 | p.Password = gosettings.OverrideWithComparable(p.Password, other.Password) 49 | p.Database = gosettings.OverrideWithComparable(p.Database, other.Database) 50 | } 51 | 52 | func (p *PostgresDatabase) read(r *reader.Reader) { 53 | p.Address = r.String("POSTGRES_ADDRESS") 54 | p.User = r.String("POSTGRES_USER", reader.ForceLowercase(false)) 55 | p.Password = r.String("POSTGRES_PASSWORD", reader.ForceLowercase(false)) 56 | p.Database = r.String("POSTGRES_DATABASE", reader.ForceLowercase(false)) 57 | } 58 | -------------------------------------------------------------------------------- /internal/config/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/qdm12/gosettings/reader" 7 | "github.com/qdm12/gotree" 8 | ) 9 | 10 | type Settings struct { 11 | HTTP HTTP 12 | Metrics Metrics 13 | Log Log 14 | Database Database 15 | Health Health 16 | } 17 | 18 | func (s *Settings) SetDefaults() { 19 | s.HTTP.setDefaults() 20 | s.Metrics.setDefaults() 21 | s.Log.setDefaults() 22 | s.Database.setDefaults() 23 | s.Health.SetDefaults() 24 | } 25 | 26 | func (s *Settings) Validate() (err error) { 27 | nameToValidation := map[string]func() error{ 28 | "http server": s.HTTP.validate, 29 | "metrics": s.Metrics.validate, 30 | "logging": s.Log.validate, 31 | "database": s.Database.validate, 32 | "health": s.Health.Validate, 33 | } 34 | 35 | for name, validation := range nameToValidation { 36 | err = validation() 37 | if err != nil { 38 | return fmt.Errorf("%s settings: %w", name, err) 39 | } 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (s *Settings) String() string { 46 | return s.toLinesNode().String() 47 | } 48 | 49 | func (s *Settings) toLinesNode() (node *gotree.Node) { 50 | node = gotree.New("Settings summary:") 51 | node.AppendNode(s.HTTP.toLinesNode()) 52 | node.AppendNode(s.Metrics.toLinesNode()) 53 | node.AppendNode(s.Log.toLinesNode()) 54 | node.AppendNode(s.Database.toLinesNode()) 55 | node.AppendNode(s.Health.toLinesNode()) 56 | return node 57 | } 58 | 59 | func (s *Settings) Copy() (copied Settings) { 60 | return Settings{ 61 | HTTP: s.HTTP.copy(), 62 | Metrics: s.Metrics.copy(), 63 | Log: s.Log.copy(), 64 | Database: s.Database.copy(), 65 | Health: s.Health.copy(), 66 | } 67 | } 68 | 69 | func (s *Settings) OverrideWith(other Settings) { 70 | s.HTTP.overrideWith(other.HTTP) 71 | s.Metrics.overrideWith(other.Metrics) 72 | s.Log.overrideWith(other.Log) 73 | s.Database.overrideWith(other.Database) 74 | s.Health.overrideWith(other.Health) 75 | } 76 | 77 | func (s *Settings) Read(r *reader.Reader) (err error) { 78 | err = s.HTTP.read(r) 79 | if err != nil { 80 | return fmt.Errorf("HTTP server settings: %w", err) 81 | } 82 | 83 | s.Metrics.read(r) 84 | s.Log.read(r) 85 | s.Database.read(r) 86 | s.Health.Read(r) 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/config/settings/websocket.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/qdm12/gosettings" 8 | "github.com/qdm12/gosettings/reader" 9 | "github.com/qdm12/gosettings/validate" 10 | "github.com/qdm12/gotree" 11 | ) 12 | 13 | type Websocket struct { 14 | Address *string 15 | } 16 | 17 | func (w *Websocket) setDefaults() { 18 | w.Address = gosettings.DefaultPointer(w.Address, ":8001") 19 | } 20 | 21 | func (w *Websocket) validate() (err error) { 22 | err = validate.ListeningAddress(*w.Address, os.Geteuid()) 23 | if err != nil { 24 | return fmt.Errorf("listening address: %w", err) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (w *Websocket) toLinesNode() (node *gotree.Node) { 31 | node = gotree.New("Websocket server settings:") 32 | node.Appendf("Server listening address: %s", *w.Address) 33 | return node 34 | } 35 | 36 | func (w *Websocket) copy() (copied Websocket) { 37 | return Websocket{ 38 | Address: gosettings.CopyPointer(w.Address), 39 | } 40 | } 41 | 42 | func (w *Websocket) overrideWith(other Websocket) { 43 | w.Address = gosettings.OverrideWithPointer(w.Address, other.Address) 44 | } 45 | 46 | func (w *Websocket) read(r *reader.Reader) { 47 | w.Address = r.Get("HTTP_WEBSOCKET_SERVER_ADDRESS") 48 | } 49 | -------------------------------------------------------------------------------- /internal/data/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Package errors contains database errors common to all implementations. 2 | package errors 3 | 4 | import "errors" 5 | 6 | // Shared errors package for all implementation of the database 7 | 8 | var ( 9 | ErrReadFile = errors.New("cannot read file") 10 | ErrWriteFile = errors.New("cannot write data to file") 11 | ErrEncoding = errors.New("failed encoding data to write") 12 | ErrDecoding = errors.New("failed decoding data read") 13 | 14 | ErrUserNotFound = errors.New("user not found") 15 | ) 16 | -------------------------------------------------------------------------------- /internal/data/interface.go: -------------------------------------------------------------------------------- 1 | // Package data contains a Database interface with multiple implementations. 2 | package data 3 | 4 | import ( 5 | "github.com/qdm12/go-template/internal/config/settings" 6 | "github.com/qdm12/go-template/internal/data/json" 7 | "github.com/qdm12/go-template/internal/data/memory" 8 | "github.com/qdm12/go-template/internal/data/psql" 9 | "github.com/qdm12/log" 10 | ) 11 | 12 | func NewMemory() (db *memory.Database, err error) { 13 | return memory.NewDatabase() 14 | } 15 | 16 | func NewJSON(filepath string) (db *json.Database, err error) { 17 | memoryDatabase, err := memory.NewDatabase() 18 | if err != nil { 19 | return nil, err 20 | } 21 | return json.NewDatabase(memoryDatabase, filepath), nil 22 | } 23 | 24 | func NewPostgres(config settings.PostgresDatabase, logger log.LeveledLogger) ( 25 | db *psql.Database, err error, 26 | ) { 27 | return psql.NewDatabase(config, logger) 28 | } 29 | -------------------------------------------------------------------------------- /internal/data/json/database.go: -------------------------------------------------------------------------------- 1 | // Package json implements a data store using a single JSON file 2 | // and the memory package. 3 | package json 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io/fs" 11 | "os" 12 | "path/filepath" 13 | "sync" 14 | 15 | dataerrors "github.com/qdm12/go-template/internal/data/errors" 16 | "github.com/qdm12/go-template/internal/data/memory" 17 | "github.com/qdm12/go-template/internal/models" 18 | ) 19 | 20 | // Database is the JSON file implementation of the database store. 21 | type Database struct { 22 | mutex sync.Mutex 23 | memory *memory.Database 24 | filepath string 25 | } 26 | 27 | // NewDatabase creates a JSON Database object with the memory 28 | // database and filepath given. Its `Start` method will either 29 | // initialize the JSON database file or load existing data from 30 | // an existing JSON file into the memory database. 31 | func NewDatabase(memory *memory.Database, filepath string) *Database { 32 | return &Database{ 33 | memory: memory, 34 | filepath: filepath, 35 | } 36 | } 37 | 38 | func (db *Database) String() string { 39 | return "JSON file database" 40 | } 41 | 42 | func (db *Database) Start(ctx context.Context) (runError <-chan error, err error) { 43 | db.mutex.Lock() 44 | defer db.mutex.Unlock() 45 | runError, err = db.memory.Start(ctx) 46 | if err != nil { 47 | return nil, fmt.Errorf("starting memory database: %w", err) 48 | } 49 | 50 | err = db.loadDatabaseFile() 51 | if err != nil { 52 | _ = db.memory.Stop() 53 | return nil, fmt.Errorf("loading database file: %w", err) 54 | } 55 | 56 | return runError, nil 57 | } 58 | 59 | func (db *Database) Stop() (err error) { 60 | err = db.memory.Stop() 61 | if err != nil { 62 | return fmt.Errorf("stopping memory database: %w", err) 63 | } 64 | db.mutex.Lock() 65 | defer db.mutex.Unlock() // wait for ongoing operation to finish 66 | return nil 67 | } 68 | 69 | func (db *Database) writeFile() error { 70 | db.mutex.Lock() 71 | defer db.mutex.Unlock() 72 | 73 | const perms fs.FileMode = 0o600 74 | file, err := os.OpenFile(db.filepath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, perms) 75 | if err != nil { 76 | return fmt.Errorf("opening file: %w", err) 77 | } 78 | 79 | encoder := json.NewEncoder(file) 80 | err = encoder.Encode(db.memory.GetData()) 81 | if err != nil { 82 | _ = file.Close() 83 | return fmt.Errorf("encoding data to file: %w", err) 84 | } 85 | 86 | err = file.Close() 87 | if err != nil { 88 | return fmt.Errorf("closing file: %w", err) 89 | } 90 | return nil 91 | } 92 | 93 | // loadDatabaseFile loads the data from the database file 94 | // if the file exists and is not empty. If the file does not 95 | // exist, its path parent directory is created. 96 | func (db *Database) loadDatabaseFile() (err error) { 97 | file, err := os.Open(db.filepath) 98 | if err != nil { 99 | if errors.Is(err, os.ErrNotExist) { 100 | const perm fs.FileMode = 0o700 101 | return os.MkdirAll(filepath.Dir(db.filepath), perm) 102 | } 103 | return fmt.Errorf("%w: %w", dataerrors.ErrReadFile, err) 104 | } 105 | 106 | stat, err := file.Stat() 107 | if err != nil { 108 | _ = file.Close() 109 | return fmt.Errorf("%w: %w", dataerrors.ErrReadFile, err) 110 | } else if stat.Size() == 0 { // empty file 111 | _ = file.Close() 112 | return nil 113 | } 114 | 115 | decoder := json.NewDecoder(file) 116 | var data models.Data 117 | err = decoder.Decode(&data) 118 | if err != nil { 119 | _ = file.Close() 120 | return fmt.Errorf("%w: %w", dataerrors.ErrDecoding, err) 121 | } 122 | db.memory.SetData(data) 123 | 124 | err = file.Close() 125 | if err != nil { 126 | return fmt.Errorf("closing database file: %w", err) 127 | } 128 | 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/data/json/database_test.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/qdm12/go-template/internal/data/memory" 9 | "github.com/qdm12/go-template/internal/models" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_Database(t *testing.T) { 15 | t.Parallel() 16 | 17 | memory, err := memory.NewDatabase() 18 | require.NoError(t, err) 19 | 20 | filePath := filepath.Join(t.TempDir(), "database.json") 21 | 22 | // Initialize database file 23 | database := NewDatabase(memory, filePath) 24 | 25 | runError, err := database.Start(context.Background()) 26 | require.NoError(t, err) 27 | assert.Nil(t, runError) 28 | 29 | userOne := models.User{ 30 | ID: 1, 31 | } 32 | err = database.CreateUser(context.Background(), userOne) 33 | require.NoError(t, err) 34 | 35 | err = database.Stop() 36 | require.NoError(t, err) 37 | 38 | runError, err = database.Start(context.Background()) 39 | require.NoError(t, err) 40 | assert.Nil(t, runError) 41 | 42 | // Check we still get the user previously created and stored on file 43 | userRetrieved, err := database.GetUserByID(context.Background(), 1) 44 | require.NoError(t, err) 45 | assert.Equal(t, userOne, userRetrieved) 46 | 47 | userTwo := models.User{ 48 | ID: 2, 49 | } 50 | err = database.CreateUser(context.Background(), userTwo) 51 | require.NoError(t, err) 52 | 53 | // Check we still have the user previously created 54 | userRetrieved, err = database.GetUserByID(context.Background(), 1) 55 | require.NoError(t, err) 56 | assert.Equal(t, userOne, userRetrieved) 57 | 58 | // Check we have the new user 59 | userRetrieved, err = database.GetUserByID(context.Background(), 2) 60 | require.NoError(t, err) 61 | assert.Equal(t, userTwo, userRetrieved) 62 | 63 | err = database.Stop() 64 | require.NoError(t, err) 65 | } 66 | -------------------------------------------------------------------------------- /internal/data/json/users.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/qdm12/go-template/internal/models" 8 | ) 9 | 10 | func (db *Database) CreateUser(ctx context.Context, user models.User) (err error) { 11 | if err := db.memory.CreateUser(ctx, user); err != nil { 12 | return err 13 | } 14 | if err := db.writeFile(); err != nil { 15 | return fmt.Errorf("%w: for user %#v", err, user) 16 | } 17 | return nil 18 | } 19 | 20 | func (db *Database) GetUserByID(ctx context.Context, id uint64) (user models.User, err error) { 21 | return db.memory.GetUserByID(ctx, id) 22 | } 23 | -------------------------------------------------------------------------------- /internal/data/memory/database.go: -------------------------------------------------------------------------------- 1 | // Package memory implements a data store in memory only. 2 | package memory 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/qdm12/go-template/internal/models" 10 | "github.com/qdm12/goservices" 11 | ) 12 | 13 | // Database is the in memory implementation of the database store. 14 | type Database struct { 15 | sync.RWMutex 16 | data models.Data 17 | running bool 18 | } 19 | 20 | // NewDatabase creates an empty memory based database. 21 | func NewDatabase() (*Database, error) { 22 | return &Database{}, nil 23 | } 24 | 25 | func (db *Database) String() string { 26 | return "memory database" 27 | } 28 | 29 | func (db *Database) Start(_ context.Context) (runError <-chan error, err error) { 30 | db.Lock() 31 | defer db.Unlock() 32 | if db.running { 33 | return nil, fmt.Errorf("%w", goservices.ErrAlreadyStarted) 34 | } 35 | db.running = true 36 | return nil, nil //nolint:nilnil 37 | } 38 | 39 | func (db *Database) Stop() (err error) { 40 | db.Lock() 41 | defer db.Unlock() // wait for ongoing operation to finish 42 | if !db.running { 43 | return fmt.Errorf("%w", goservices.ErrAlreadyStopped) 44 | } 45 | db.running = false 46 | return nil 47 | } 48 | 49 | func (db *Database) GetData() models.Data { 50 | db.Lock() 51 | defer db.Unlock() 52 | return db.data 53 | } 54 | 55 | func (db *Database) SetData(data models.Data) { 56 | db.Lock() 57 | defer db.Unlock() 58 | db.data = data 59 | } 60 | -------------------------------------------------------------------------------- /internal/data/memory/users.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | dataerrors "github.com/qdm12/go-template/internal/data/errors" 8 | "github.com/qdm12/go-template/internal/models" 9 | ) 10 | 11 | func (db *Database) CreateUser(_ context.Context, user models.User) (err error) { 12 | db.Lock() 13 | defer db.Unlock() 14 | db.data.Users = append(db.data.Users, user) 15 | return nil 16 | } 17 | 18 | func (db *Database) GetUserByID(_ context.Context, id uint64) (user models.User, err error) { 19 | db.Lock() 20 | defer db.Unlock() 21 | for _, user := range db.data.Users { 22 | if user.ID == id { 23 | return user, nil 24 | } 25 | } 26 | return user, fmt.Errorf("%w: for id %d", dataerrors.ErrUserNotFound, id) 27 | } 28 | -------------------------------------------------------------------------------- /internal/data/psql/database.go: -------------------------------------------------------------------------------- 1 | // Package psql implements a data store using a client to a 2 | // Postgres database. 3 | package psql 4 | 5 | import ( 6 | "context" 7 | "database/sql" 8 | "fmt" 9 | "sync" 10 | "time" 11 | 12 | "github.com/qdm12/go-template/internal/config/settings" 13 | "github.com/qdm12/goservices" 14 | ) 15 | 16 | // Database is the Postgres implementation of the database store. 17 | type Database struct { 18 | startStopMutex sync.Mutex 19 | running bool 20 | sql *sql.DB 21 | logger Logger 22 | } 23 | 24 | // NewDatabase creates a database connection pool in DB and pings the database. 25 | func NewDatabase(config settings.PostgresDatabase, logger Logger) (*Database, error) { 26 | connStr := "postgres://" + config.User + ":" + config.Password + 27 | "@" + config.Address + "/" + config.Address + "?sslmode=disable&connect_timeout=1" 28 | db, err := sql.Open("postgres", connStr) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return &Database{ 33 | sql: db, 34 | logger: logger, 35 | }, nil 36 | } 37 | 38 | func (db *Database) String() string { 39 | return "postgres database" 40 | } 41 | 42 | // Start pings the database, and if it fails, retries up to 3 times 43 | // before returning a start error. 44 | func (db *Database) Start(ctx context.Context) (runError <-chan error, err error) { 45 | db.startStopMutex.Lock() 46 | defer db.startStopMutex.Unlock() 47 | 48 | if db.running { 49 | return nil, fmt.Errorf("%w", goservices.ErrAlreadyStarted) 50 | } 51 | 52 | fails := 0 53 | const maxFails = 3 54 | const sleepDuration = 200 * time.Millisecond 55 | var totalTryTime time.Duration 56 | for { 57 | err = db.sql.PingContext(ctx) 58 | if err == nil { 59 | break 60 | } else if ctx.Err() != nil { 61 | return nil, fmt.Errorf("pinging database: %w", err) 62 | } 63 | fails++ 64 | if fails == maxFails { 65 | return nil, fmt.Errorf("failed connecting to database after %d tries in %s: %w", fails, totalTryTime, err) 66 | } 67 | time.Sleep(sleepDuration) 68 | totalTryTime += sleepDuration 69 | } 70 | 71 | db.running = true 72 | // TODO have periodic ping to check connection is still alive 73 | // and signal through the run error channel. 74 | return nil, nil //nolint:nilnil 75 | } 76 | 77 | // Stop stops the database and closes the connection. 78 | func (db *Database) Stop() (err error) { 79 | db.startStopMutex.Lock() 80 | defer db.startStopMutex.Unlock() 81 | if !db.running { 82 | return fmt.Errorf("%w", goservices.ErrAlreadyStopped) 83 | } 84 | 85 | err = db.sql.Close() 86 | if err != nil { 87 | return fmt.Errorf("closing database connection: %w", err) 88 | } 89 | 90 | db.running = false 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/data/psql/interfaces.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | type Logger interface{} 4 | -------------------------------------------------------------------------------- /internal/data/psql/users.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | dataerrors "github.com/qdm12/go-template/internal/data/errors" 10 | "github.com/qdm12/go-template/internal/models" 11 | ) 12 | 13 | // CreateUser inserts a user in the database. 14 | func (db *Database) CreateUser(ctx context.Context, user models.User) (err error) { 15 | _, err = db.sql.ExecContext(ctx, 16 | "INSERT INTO users(id, account, username, email) VALUES ($1,$2,$3,$4);", 17 | user.ID, 18 | user.Account, 19 | user.Username, 20 | user.Email, 21 | ) 22 | if err != nil { 23 | return err 24 | } 25 | return nil 26 | } 27 | 28 | // GetUserByID returns the user corresponding to a user ID from the database. 29 | func (db *Database) GetUserByID(ctx context.Context, id uint64) (user models.User, err error) { 30 | row := db.sql.QueryRowContext(ctx, 31 | "SELECT account, email, username FROM users WHERE id = $1;", 32 | id, 33 | ) 34 | user.ID = id 35 | err = row.Scan(&user.Account, &user.Email, &user.Username) 36 | if errors.Is(err, sql.ErrNoRows) { 37 | return user, fmt.Errorf("%w: for id %d", dataerrors.ErrUserNotFound, id) 38 | } else if err != nil { 39 | return user, fmt.Errorf("%w: for id %d", err, id) 40 | } 41 | return user, nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/health/client.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | func IsClientMode(args []string) bool { 14 | return len(args) > 1 && args[1] == "healthcheck" 15 | } 16 | 17 | type Client struct { 18 | *http.Client 19 | } 20 | 21 | func NewClient() *Client { 22 | const timeout = 5 * time.Second 23 | return &Client{ 24 | Client: &http.Client{Timeout: timeout}, 25 | } 26 | } 27 | 28 | var ( 29 | ErrParseHealthServerAddress = errors.New("cannot parse health server address") 30 | ErrQuery = errors.New("cannot query health server") 31 | ErrUnhealthy = errors.New("unhealthy") 32 | ) 33 | 34 | // Query sends an HTTP request to the other instance of 35 | // the program, and to its internal healthcheck server. 36 | func (c *Client) Query(ctx context.Context, address string) error { 37 | _, port, err := net.SplitHostPort(address) 38 | if err != nil { 39 | return fmt.Errorf("%w: %s: %w", ErrParseHealthServerAddress, address, err) 40 | } 41 | 42 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port, nil) 43 | if err != nil { 44 | return fmt.Errorf("%w: %w", ErrQuery, err) 45 | } 46 | resp, err := c.Do(req) 47 | if err != nil { 48 | return fmt.Errorf("%w: %w", ErrQuery, err) 49 | } else if resp.StatusCode == http.StatusOK { 50 | return nil 51 | } 52 | 53 | b, err := io.ReadAll(resp.Body) 54 | defer resp.Body.Close() 55 | if err != nil { 56 | return fmt.Errorf("%w: %s: %w", ErrUnhealthy, resp.Status, err) 57 | } 58 | return fmt.Errorf("%w: %s", ErrUnhealthy, string(b)) 59 | } 60 | -------------------------------------------------------------------------------- /internal/health/handler.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func NewHandler(logger Logger, healthcheck func() error) *Handler { 8 | return &Handler{ 9 | logger: logger, 10 | healthcheck: healthcheck, 11 | } 12 | } 13 | 14 | type Handler struct { 15 | logger Logger 16 | healthcheck func() error 17 | } 18 | 19 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 | if r.Method != http.MethodGet || (r.RequestURI != "" && r.RequestURI != "/") { 21 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 22 | return 23 | } 24 | if err := h.healthcheck(); err != nil { 25 | http.Error(w, err.Error(), http.StatusInternalServerError) 26 | return 27 | } 28 | w.WriteHeader(http.StatusOK) 29 | } 30 | -------------------------------------------------------------------------------- /internal/health/health.go: -------------------------------------------------------------------------------- 1 | // Package health contains healthchecking tooling such as an HTTP server 2 | // and the corresponding HTTP client, only for healthchecks. 3 | package health 4 | -------------------------------------------------------------------------------- /internal/health/interfaces.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | type Logger interface { 4 | Info(s string) 5 | } 6 | -------------------------------------------------------------------------------- /internal/metrics/constants.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | const ( 4 | promNamespace = "namespace" 5 | promSubsystem = "subsystem" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/metrics/helpers.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | var ErrRegister = errors.New("registration error") 12 | 13 | func newCounterVec(name, help string, labelNames []string, register bool) (c *prometheus.CounterVec, err error) { 14 | c = prometheus.NewCounterVec(prometheus.CounterOpts{ 15 | Namespace: promNamespace, 16 | Subsystem: promSubsystem, 17 | Name: name, 18 | Help: help, 19 | }, labelNames) 20 | if register { 21 | if err := prometheus.Register(c); err != nil { 22 | return nil, fmt.Errorf("%w: %w", ErrRegister, err) 23 | } 24 | } 25 | return c, nil 26 | } 27 | 28 | func newGauge(name, help string, register bool) ( 29 | g prometheus.Gauge, err error, 30 | ) { 31 | g = prometheus.NewGauge(prometheus.GaugeOpts{ 32 | Namespace: promNamespace, 33 | Subsystem: promSubsystem, 34 | Name: name, 35 | Help: help, 36 | }) 37 | if register { 38 | if err := prometheus.Register(g); err != nil { 39 | return nil, fmt.Errorf("%w: %w", ErrRegister, err) 40 | } 41 | } 42 | return g, nil 43 | } 44 | 45 | func newHistogramVec(name, help string, buckets []float64, labelNames []string, register bool) ( 46 | h *prometheus.HistogramVec, err error, 47 | ) { 48 | h = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 49 | Namespace: promNamespace, 50 | Subsystem: promSubsystem, 51 | Name: name, 52 | Help: help, 53 | Buckets: buckets, 54 | }, labelNames) 55 | if register { 56 | if err := prometheus.Register(h); err != nil { 57 | return nil, fmt.Errorf("%w: %w", ErrRegister, err) 58 | } 59 | } 60 | return h, nil 61 | } 62 | 63 | func newResponseTimeHistogramVec(register bool) (responseTimeHistogram *prometheus.HistogramVec, err error) { 64 | //nolint:mnd 65 | buckets := []float64{ 66 | float64(time.Millisecond), 67 | float64(10 * time.Millisecond), 68 | float64(50 * time.Millisecond), 69 | float64(100 * time.Millisecond), 70 | float64(150 * time.Millisecond), 71 | float64(200 * time.Millisecond), 72 | float64(500 * time.Millisecond), 73 | float64(750 * time.Millisecond), 74 | float64(time.Second), 75 | float64(2 * time.Second), 76 | float64(5 * time.Second), 77 | float64(10 * time.Second), 78 | } 79 | return newHistogramVec("response_time", 80 | "Histogram for the response times by handler and HTTP status", 81 | buckets, 82 | []string{"handler", "status"}, 83 | register) 84 | } 85 | -------------------------------------------------------------------------------- /internal/metrics/interfaces.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type Logger interface { 4 | Info(s string) 5 | } 6 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | // Package metrics contains a metrics interface with methods to modify the 2 | // metrics for Prometheus. 3 | package metrics 4 | 5 | import ( 6 | "net/http" 7 | "time" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | ) 11 | 12 | type Metrics struct { 13 | requestsCounter *prometheus.CounterVec 14 | responseBytesCounter *prometheus.CounterVec 15 | inFlighRequestsGauge prometheus.Gauge 16 | responseTimeHistogram *prometheus.HistogramVec 17 | } 18 | 19 | func New(register bool) (m *Metrics, err error) { 20 | requestsCounter, err := newCounterVec( 21 | "requests", 22 | "Counter for the number of requests by handler and HTTP status", 23 | []string{"handler", "status"}, register) 24 | if err != nil { 25 | return nil, err 26 | } 27 | responseBytesCounter, err := newCounterVec( 28 | "response_bytes", 29 | "Counter for the number of bytes written in the response by handler and HTTP status", 30 | []string{"handler", "status"}, register) 31 | if err != nil { 32 | return nil, err 33 | } 34 | inFlighRequestsGauge, err := newGauge( 35 | "requests_inflight", 36 | "Gauge for the current number of inflight requests by handler and HTTP status", 37 | register) 38 | if err != nil { 39 | return nil, err 40 | } 41 | responseTimeHistogram, err := newResponseTimeHistogramVec(register) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &Metrics{ 47 | requestsCounter: requestsCounter, 48 | responseBytesCounter: responseBytesCounter, 49 | inFlighRequestsGauge: inFlighRequestsGauge, 50 | responseTimeHistogram: responseTimeHistogram, 51 | }, nil 52 | } 53 | 54 | func (m *Metrics) RequestCountInc(routePattern string, statusCode int) { 55 | m.requestsCounter.WithLabelValues(routePattern, http.StatusText(statusCode)).Inc() 56 | } 57 | 58 | func (m *Metrics) ResponseBytesCountAdd(routePattern string, statusCode int, bytesWritten int) { 59 | m.responseBytesCounter.WithLabelValues(routePattern, http.StatusText(statusCode)).Add(float64(bytesWritten)) 60 | } 61 | 62 | func (m *Metrics) InflightRequestsGaugeAdd(addition int) { 63 | m.inFlighRequestsGauge.Add(float64(addition)) 64 | } 65 | 66 | func (m *Metrics) ResponseTimeHistogramObserve(routePattern string, statusCode int, duration time.Duration) { 67 | m.responseTimeHistogram.WithLabelValues(routePattern, http.StatusText(statusCode)).Observe(duration.Seconds()) 68 | } 69 | -------------------------------------------------------------------------------- /internal/models/build.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type BuildInformation struct { 4 | Version string `json:"version"` 5 | Commit string `json:"commit"` 6 | BuildDate string `json:"buildDate"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/models/models.go: -------------------------------------------------------------------------------- 1 | // Package models contains data structures common through the program. 2 | package models 3 | 4 | type User struct { 5 | ID uint64 `json:"id"` 6 | Account string `json:"account"` 7 | Username string `json:"username"` 8 | Email string `json:"email"` 9 | } 10 | 11 | type Data struct { 12 | Users []User `json:"users"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/processor/interfaces.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/qdm12/go-template/internal/models" 7 | ) 8 | 9 | type Database interface { 10 | CreateUser(ctx context.Context, user models.User) error 11 | GetUserByID(ctx context.Context, id uint64) (user models.User, err error) 12 | } 13 | -------------------------------------------------------------------------------- /internal/processor/processor.go: -------------------------------------------------------------------------------- 1 | // Package processor contains operations the server can run and 2 | // serves as the middle ground between the network server and 3 | // the data store. 4 | package processor 5 | 6 | type Processor struct { 7 | db Database 8 | } 9 | 10 | // NewProcessor creates a new Processor object. 11 | func NewProcessor(db Database) *Processor { 12 | return &Processor{ 13 | db: db, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/processor/users.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | dataerr "github.com/qdm12/go-template/internal/data/errors" 9 | "github.com/qdm12/go-template/internal/models" 10 | ) 11 | 12 | var ErrUserNotFound = errors.New("user not found") 13 | 14 | func (p *Processor) CreateUser(ctx context.Context, user models.User) error { 15 | return p.db.CreateUser(ctx, user) 16 | } 17 | 18 | func (p *Processor) GetUserByID(ctx context.Context, id uint64) (user models.User, err error) { 19 | user, err = p.db.GetUserByID(ctx, id) 20 | if errors.Is(err, dataerr.ErrUserNotFound) { 21 | err = fmt.Errorf("%w: %w", ErrUserNotFound, errors.Unwrap(err)) 22 | } 23 | return user, err 24 | } 25 | -------------------------------------------------------------------------------- /internal/server/contenttype/apicheck.go: -------------------------------------------------------------------------------- 1 | package contenttype 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | func APICheck(header http.Header) ( 10 | requestContentType, responseContentType string, err error, 11 | ) { 12 | accept := header.Get("Accept") 13 | if accept == "" { 14 | accept = JSON 15 | } 16 | 17 | acceptedTypes := strings.Split(accept, ",") 18 | for _, acceptedType := range acceptedTypes { 19 | acceptedType = strings.TrimSpace(acceptedType) 20 | switch acceptedType { 21 | case JSON: 22 | responseContentType = acceptedType 23 | case HTML: 24 | responseContentType = JSON // override for browser access to the API 25 | } 26 | if responseContentType != "" { 27 | break 28 | } 29 | } 30 | 31 | if responseContentType == "" { 32 | responseContentType = JSON 33 | return "", responseContentType, fmt.Errorf("%w: %s", ErrRespContentTypeNotSupported, accept) 34 | } 35 | 36 | requestContentType = header.Get("Content-Type") 37 | requestContentType = strings.TrimSpace(requestContentType) 38 | if requestContentType == "" { 39 | requestContentType = JSON 40 | } 41 | if requestContentType != JSON { 42 | return "", responseContentType, fmt.Errorf("%w: %q", ErrContentTypeNotSupported, requestContentType) 43 | } 44 | 45 | return requestContentType, responseContentType, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/server/contenttype/apicheck_test.go: -------------------------------------------------------------------------------- 1 | package contenttype 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func Test_APICheck(t *testing.T) { 13 | t.Parallel() 14 | 15 | testCases := map[string]struct { 16 | requestHeader http.Header 17 | requestContentType string 18 | responseContentType string 19 | err error 20 | }{ 21 | "empty header": { 22 | requestHeader: http.Header{}, 23 | requestContentType: "application/json", 24 | responseContentType: "application/json", 25 | }, 26 | "header with valid accept and content type": { 27 | requestHeader: http.Header{ 28 | "Accept": []string{"application/json"}, 29 | "Content-Type": []string{"application/json"}, 30 | }, 31 | requestContentType: "application/json", 32 | responseContentType: "application/json", 33 | }, 34 | "header with html accept": { 35 | requestHeader: http.Header{ 36 | "Accept": []string{"text/html"}, 37 | }, 38 | requestContentType: "application/json", 39 | responseContentType: "application/json", 40 | }, 41 | "header with invalid accept": { 42 | requestHeader: http.Header{ 43 | "Accept": []string{"invalid, invalid2"}, 44 | "Content-Type": []string{"application/json"}, 45 | }, 46 | responseContentType: "application/json", 47 | err: errors.New("no response content type supported: invalid, invalid2"), 48 | }, 49 | "header with one valid accept of many": { 50 | requestHeader: http.Header{ 51 | "Accept": []string{"invalid,application/json ,invalid"}, 52 | "Content-Type": []string{"application/json"}, 53 | }, 54 | requestContentType: "application/json", 55 | responseContentType: "application/json", 56 | }, 57 | "header with invalid content type": { 58 | requestHeader: http.Header{ 59 | "Content-Type": []string{"invalid"}, 60 | }, 61 | responseContentType: "application/json", 62 | err: errors.New(`content type is not supported: "invalid"`), 63 | }, 64 | } 65 | for name, testCase := range testCases { 66 | t.Run(name, func(t *testing.T) { 67 | t.Parallel() 68 | 69 | requestContentType, responseContentType, err := APICheck(testCase.requestHeader) 70 | 71 | if testCase.err != nil { 72 | require.Error(t, err) 73 | assert.Equal(t, testCase.err.Error(), err.Error()) 74 | } else { 75 | assert.NoError(t, err) 76 | } 77 | 78 | assert.Equal(t, testCase.requestContentType, requestContentType) 79 | assert.Equal(t, testCase.responseContentType, responseContentType) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/server/contenttype/contenttype.go: -------------------------------------------------------------------------------- 1 | // Package contenttype contains functions to extract content type 2 | // information from request headers as well as set correct headers 3 | // on the response depending on the Accept and Content-Type request 4 | // headers. 5 | package contenttype 6 | -------------------------------------------------------------------------------- /internal/server/contenttype/contenttypes.go: -------------------------------------------------------------------------------- 1 | package contenttype 2 | 3 | const ( 4 | JSON = "application/json" 5 | HTML = "text/html" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/server/contenttype/errors.go: -------------------------------------------------------------------------------- 1 | package contenttype 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrContentTypeNotSupported = errors.New("content type is not supported") 7 | ErrRespContentTypeNotSupported = errors.New("no response content type supported") 8 | ) 9 | -------------------------------------------------------------------------------- /internal/server/decodejson/decodejson.go: -------------------------------------------------------------------------------- 1 | // Package decodejson has helper functions to decode HTTP bodies encoded in JSON. 2 | package decodejson 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/qdm12/go-template/internal/server/httperr" 13 | ) 14 | 15 | // DecodeBody decodes the HTTP JSON encoded body into v. 16 | // If the decoding succeeds, ok is returned as true. 17 | // If the decoding fails, the function writes an error over 18 | // HTTP to the client and returns ok as false. 19 | // If writing the response to the client fails, an non-nil 20 | // `errorResponseErr` error is returned as well. 21 | // Therefore the caller should check for `errorResponseErr` 22 | // every time `ok` is false. 23 | func DecodeBody(w http.ResponseWriter, maxBytes int64, 24 | body io.ReadCloser, v any, responseContentType string) ( 25 | ok bool, responseErr error, 26 | ) { 27 | body = http.MaxBytesReader(w, body, maxBytes) 28 | 29 | decoder := json.NewDecoder(body) 30 | decoder.DisallowUnknownFields() 31 | 32 | err := decoder.Decode(v) 33 | if err != nil { 34 | errString, errCode := extractFromJSONErr(err) 35 | responseErr = httperr.Respond(w, errCode, errString, responseContentType) 36 | return false, responseErr 37 | } 38 | 39 | err = decoder.Decode(&struct{}{}) 40 | if errors.Is(err, io.EOF) { 41 | return true, nil 42 | } 43 | const errString = "request body must only contain a single JSON object" 44 | responseErr = httperr.Respond(w, http.StatusBadRequest, errString, responseContentType) 45 | return false, responseErr 46 | } 47 | 48 | func extractFromJSONErr(err error) (errString string, errCode int) { 49 | var ( 50 | syntaxError *json.SyntaxError 51 | unmarshalTypeError *json.UnmarshalTypeError 52 | ) 53 | switch { 54 | case errors.As(err, &syntaxError): 55 | const format = "request body contains badly-formed JSON (at position %d)" 56 | return fmt.Sprintf(format, syntaxError.Offset), http.StatusBadRequest 57 | 58 | case errors.Is(err, io.ErrUnexpectedEOF): 59 | return "request body contains badly-formed JSON", http.StatusBadRequest 60 | 61 | case errors.As(err, &unmarshalTypeError): 62 | const format = "request body contains an invalid value for the %q field (at position %d)" 63 | return fmt.Sprintf(format, unmarshalTypeError.Field, unmarshalTypeError.Offset), http.StatusBadRequest 64 | 65 | case strings.HasPrefix(err.Error(), "json: unknown field "): 66 | fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") 67 | const format = "request body contains unknown field %s" 68 | return fmt.Sprintf(format, fieldName), http.StatusBadRequest 69 | 70 | case errors.Is(err, io.EOF): 71 | return "request body cannot be empty", http.StatusBadRequest 72 | 73 | case err.Error() == "http: request body too large": 74 | return "request body is too large", http.StatusRequestEntityTooLarge 75 | 76 | default: 77 | return err.Error(), http.StatusInternalServerError 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/server/decodejson/decodejson_test.go: -------------------------------------------------------------------------------- 1 | package decodejson 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/qdm12/go-template/internal/server/contenttype" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func Test_DecodeBody(t *testing.T) { 18 | t.Parallel() 19 | 20 | type exampleStruct struct { 21 | A int `json:"a"` 22 | } 23 | 24 | testCases := map[string]struct { 25 | maxBytes int64 26 | requestBody string 27 | v any 28 | expectedV any 29 | ok bool 30 | responseErrWrapped error 31 | responseErrMessage string 32 | status int 33 | responseBody string 34 | }{ 35 | "success": { 36 | maxBytes: 1024, 37 | requestBody: `{"a":1}`, 38 | v: &exampleStruct{}, 39 | expectedV: &exampleStruct{A: 1}, 40 | ok: true, 41 | status: http.StatusOK, 42 | }, 43 | "max size": { 44 | maxBytes: 2, 45 | requestBody: `{"a":1}`, 46 | v: &exampleStruct{}, 47 | ok: false, 48 | status: http.StatusRequestEntityTooLarge, 49 | responseBody: `{"error":"request body is too large"} 50 | `, 51 | }, 52 | "unknown field": { 53 | maxBytes: 1024, 54 | requestBody: `{"a":1,"b":2}`, 55 | v: &exampleStruct{}, 56 | ok: false, 57 | status: http.StatusBadRequest, 58 | responseBody: `{"error":"request body contains unknown field \"b\""} 59 | `, 60 | }, 61 | "extra after JSON": { 62 | maxBytes: 1024, 63 | requestBody: `{"a":1}\n`, 64 | v: &exampleStruct{}, 65 | ok: false, 66 | status: http.StatusBadRequest, 67 | responseBody: `{"error":"request body must only contain a single JSON object"} 68 | `, 69 | }, 70 | } 71 | for name, testCase := range testCases { 72 | t.Run(name, func(t *testing.T) { 73 | t.Parallel() 74 | w := httptest.NewRecorder() 75 | requestBody := io.NopCloser( 76 | strings.NewReader(testCase.requestBody), 77 | ) 78 | 79 | ok, responseErr := DecodeBody( 80 | w, testCase.maxBytes, requestBody, testCase.v, contenttype.JSON) 81 | 82 | require.Equal(t, testCase.ok, ok) 83 | assert.ErrorIs(t, responseErr, testCase.responseErrWrapped) 84 | if testCase.responseErrWrapped != nil { 85 | assert.EqualError(t, responseErr, testCase.responseErrMessage) 86 | } 87 | bytes, err := io.ReadAll(w.Body) 88 | require.NoError(t, err) 89 | responseBody := string(bytes) 90 | assert.Equal(t, testCase.status, w.Code) 91 | assert.Equal(t, testCase.responseBody, responseBody) 92 | }) 93 | } 94 | } 95 | 96 | func Test_extractFromJSONErr(t *testing.T) { 97 | t.Parallel() 98 | 99 | testCases := map[string]struct { 100 | err error 101 | errString string 102 | errCode int 103 | }{ 104 | "syntax error": { 105 | err: &json.SyntaxError{Offset: 1}, 106 | errString: "request body contains badly-formed JSON (at position 1)", 107 | errCode: http.StatusBadRequest, 108 | }, 109 | "unexpected EOF": { 110 | err: io.ErrUnexpectedEOF, 111 | errString: "request body contains badly-formed JSON", 112 | errCode: http.StatusBadRequest, 113 | }, 114 | "unmarshal type error": { 115 | err: &json.UnmarshalTypeError{}, 116 | errString: `request body contains an invalid value for the "" field (at position 0)`, 117 | errCode: http.StatusBadRequest, 118 | }, 119 | "unknown field": { 120 | err: errors.New("json: unknown field bla"), 121 | errString: `request body contains unknown field bla`, 122 | errCode: http.StatusBadRequest, 123 | }, 124 | "EOF": { 125 | err: io.EOF, 126 | errString: `request body cannot be empty`, 127 | errCode: http.StatusBadRequest, 128 | }, 129 | "request too large": { 130 | err: errors.New("http: request body too large"), 131 | errString: `request body is too large`, 132 | errCode: http.StatusRequestEntityTooLarge, 133 | }, 134 | "default error": { 135 | err: errors.New("some error"), 136 | errString: `some error`, 137 | errCode: http.StatusInternalServerError, 138 | }, 139 | } 140 | for name, testCase := range testCases { 141 | t.Run(name, func(t *testing.T) { 142 | t.Parallel() 143 | errString, errCode := extractFromJSONErr(testCase.err) 144 | assert.Equal(t, testCase.errString, errString) 145 | assert.Equal(t, testCase.errCode, errCode) 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /internal/server/fileserver/fileserver.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/go-chi/chi/v5" 8 | ) 9 | 10 | func Serve(router chi.Router, path string, root http.FileSystem) { 11 | if path != "/" && path[len(path)-1] != '/' { 12 | router.Get(path, 13 | http.RedirectHandler(path+"/", http.StatusMovedPermanently).ServeHTTP) 14 | path += "/" 15 | } 16 | path += "*" 17 | 18 | router.Get(path, func(w http.ResponseWriter, r *http.Request) { 19 | rctx := chi.RouteContext(r.Context()) 20 | pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*") 21 | fs := http.StripPrefix(pathPrefix, http.FileServer(root)) 22 | fs.ServeHTTP(w, r) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /internal/server/httperr/httperr.go: -------------------------------------------------------------------------------- 1 | // Package httperr implements convenience functions to respond 2 | // with an error to an http client. 3 | package httperr 4 | -------------------------------------------------------------------------------- /internal/server/httperr/respond.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/qdm12/go-template/internal/server/contenttype" 9 | ) 10 | 11 | type errJSONWrapper struct { 12 | Error string `json:"error"` 13 | } 14 | 15 | func Respond(w http.ResponseWriter, status int, 16 | errString, contentType string, 17 | ) (err error) { 18 | w.WriteHeader(status) 19 | if errString == "" { 20 | errString = http.StatusText(status) 21 | } 22 | switch contentType { 23 | case contenttype.JSON: 24 | body := errJSONWrapper{Error: errString} 25 | err = json.NewEncoder(w).Encode(body) 26 | if err != nil { 27 | return fmt.Errorf("encoding and writing JSON response: %w", err) 28 | } 29 | default: 30 | _, err = w.Write([]byte(errString)) 31 | if err != nil { 32 | return fmt.Errorf("writing raw error string: %w", err) 33 | } 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/server/httperr/respond_test.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/qdm12/go-template/internal/server/contenttype" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_Respond(t *testing.T) { 15 | t.Parallel() 16 | 17 | testCases := map[string]struct { 18 | status int 19 | errString string 20 | expectedBody string 21 | }{ 22 | "status without error string": { 23 | status: http.StatusBadRequest, 24 | expectedBody: `{"error":"Bad Request"} 25 | `, 26 | }, 27 | "status with error string": { 28 | status: http.StatusBadRequest, 29 | errString: "bad parameter", 30 | expectedBody: `{"error":"bad parameter"} 31 | `, 32 | }, 33 | } 34 | for name, testCase := range testCases { 35 | t.Run(name, func(t *testing.T) { 36 | t.Parallel() 37 | 38 | w := httptest.NewRecorder() 39 | 40 | err := Respond(w, testCase.status, testCase.errString, contenttype.JSON) 41 | require.NoError(t, err) 42 | 43 | response := w.Result() 44 | defer response.Body.Close() 45 | bytes, err := io.ReadAll(response.Body) 46 | require.NoError(t, err) 47 | body := string(bytes) 48 | 49 | assert.Equal(t, testCase.status, response.StatusCode) 50 | assert.Equal(t, testCase.expectedBody, body) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/server/httperr/responder.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type Responder struct { 8 | contentType string 9 | logger Logger 10 | } 11 | 12 | type Logger interface { 13 | Debugf(format string, args ...any) 14 | } 15 | 16 | func NewResponder(contentType string, logger Logger) *Responder { 17 | return &Responder{ 18 | contentType: contentType, 19 | logger: logger, 20 | } 21 | } 22 | 23 | // Respond responds the given error string and HTTP status 24 | // to the given http response writer. 25 | // If an error occurs responding, it is logged as a warning by 26 | // the responder warner. 27 | func (r *Responder) Respond(w http.ResponseWriter, status int, 28 | errString string, 29 | ) { 30 | err := Respond(w, status, errString, r.contentType) 31 | if err != nil { 32 | r.logger.Debugf("responding error: %s", err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/server/interfaces.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/qdm12/go-template/internal/models" 8 | ) 9 | 10 | type Logger interface { 11 | Debugf(format string, args ...any) 12 | Infof(format string, args ...any) 13 | Error(s string) 14 | } 15 | 16 | type Metrics interface { 17 | RequestCountInc(routePattern string, statusCode int) 18 | ResponseBytesCountAdd(routePattern string, statusCode int, bytesWritten int) 19 | InflightRequestsGaugeAdd(addition int) 20 | ResponseTimeHistogramObserve(routePattern string, statusCode int, duration time.Duration) 21 | } 22 | 23 | type Processor interface { 24 | CreateUser(ctx context.Context, user models.User) error 25 | GetUserByID(ctx context.Context, id uint64) (user models.User, err error) 26 | } 27 | -------------------------------------------------------------------------------- /internal/server/middlewares/cors/cors.go: -------------------------------------------------------------------------------- 1 | // Package cors has a middleware and functions to parse and set 2 | // CORS correctly. 3 | package cors 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | errNoOriginHeader = errors.New("no origin header found") 14 | errOriginNotAllowed = errors.New("origin not allowed") 15 | ) 16 | 17 | func setCrossOriginHeaders(requestHeaders, responseHeaders http.Header, 18 | allowedOrigins map[string]struct{}, allowedHeaders []string, 19 | ) error { 20 | origin := requestHeaders.Get("Origin") 21 | if len(origin) == 0 { 22 | return errNoOriginHeader 23 | } 24 | 25 | if _, ok := allowedOrigins[origin]; !ok { 26 | return fmt.Errorf("%w: %s", errOriginNotAllowed, origin) 27 | } 28 | 29 | responseHeaders.Set("Access-Control-Allow-Origin", origin) 30 | responseHeaders.Set("Access-Control-Max-Age", "14400") // 4 hours 31 | for i := range allowedHeaders { 32 | responseHeaders.Add("Access-Control-Allow-Headers", allowedHeaders[i]) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func AllowCORSMethods(r *http.Request, w http.ResponseWriter, methods ...string) { 39 | methods = append(methods, http.MethodOptions) 40 | w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ", ")) 41 | 42 | requestMethod := r.Header.Get("Access-Control-Request-Method") 43 | for _, method := range methods { 44 | if method == requestMethod { 45 | w.WriteHeader(http.StatusOK) 46 | return 47 | } 48 | } 49 | 50 | w.WriteHeader(http.StatusMethodNotAllowed) 51 | } 52 | -------------------------------------------------------------------------------- /internal/server/middlewares/cors/cors_test.go: -------------------------------------------------------------------------------- 1 | package cors 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_setCrossOriginHeaders(t *testing.T) { 13 | t.Parallel() 14 | testCases := map[string]struct { 15 | requestHeaders http.Header 16 | responseHeaders http.Header 17 | allowedOrigins map[string]struct{} 18 | allowedHeaders []string 19 | expectedResponseHeaders http.Header 20 | err error 21 | }{ 22 | "no origin": { 23 | requestHeaders: http.Header{}, 24 | responseHeaders: http.Header{}, 25 | expectedResponseHeaders: http.Header{}, 26 | err: errNoOriginHeader, 27 | }, 28 | "origin not allowed": { 29 | requestHeaders: http.Header{ 30 | "Origin": []string{"not-allowed"}, 31 | }, 32 | responseHeaders: http.Header{}, 33 | allowedOrigins: map[string]struct{}{}, 34 | expectedResponseHeaders: http.Header{}, 35 | err: errors.New("origin not allowed: not-allowed"), 36 | }, 37 | "origin allowed": { 38 | requestHeaders: http.Header{ 39 | "Origin": []string{"allowed"}, 40 | }, 41 | responseHeaders: http.Header{ 42 | "key": []string{"value"}, 43 | }, 44 | allowedOrigins: map[string]struct{}{"allowed": {}}, 45 | allowedHeaders: []string{"Authorization", "HeaderKey"}, 46 | expectedResponseHeaders: http.Header{ 47 | "key": []string{"value"}, 48 | "Access-Control-Allow-Origin": []string{"allowed"}, 49 | "Access-Control-Max-Age": []string{"14400"}, 50 | "Access-Control-Allow-Headers": []string{"Authorization", "HeaderKey"}, 51 | }, 52 | }, 53 | } 54 | for name, testCase := range testCases { 55 | t.Run(name, func(t *testing.T) { 56 | t.Parallel() 57 | requestHeaders := testCase.requestHeaders.Clone() 58 | responseHeaders := testCase.responseHeaders 59 | 60 | err := setCrossOriginHeaders(requestHeaders, responseHeaders, 61 | testCase.allowedOrigins, testCase.allowedHeaders) 62 | 63 | if testCase.err != nil { 64 | assert.Error(t, err) 65 | assert.Equal(t, testCase.err.Error(), err.Error()) 66 | } else { 67 | assert.NoError(t, err) 68 | } 69 | 70 | assert.Equal(t, testCase.requestHeaders, requestHeaders) 71 | assert.Equal(t, testCase.expectedResponseHeaders, responseHeaders) 72 | }) 73 | } 74 | } 75 | 76 | func Test_AllowCORSMethods(t *testing.T) { 77 | t.Parallel() 78 | 79 | optionsRequestWithHeader := func(header http.Header) (req *http.Request) { 80 | req = httptest.NewRequest(http.MethodOptions, "http://test.com", nil) 81 | req.Header = header 82 | return req 83 | } 84 | 85 | testCases := map[string]struct { 86 | request *http.Request 87 | methods []string 88 | respHeader http.Header 89 | status int 90 | }{ 91 | "no request method header": { 92 | request: optionsRequestWithHeader(http.Header{}), 93 | methods: []string{"POST", "PUT"}, 94 | respHeader: http.Header{ 95 | "Access-Control-Allow-Methods": []string{"POST, PUT, OPTIONS"}, 96 | }, 97 | status: http.StatusMethodNotAllowed, 98 | }, 99 | "not accepted request method header": { 100 | request: optionsRequestWithHeader(http.Header{ 101 | "Access-Control-Request-Method": []string{"DELETE"}, 102 | }), 103 | methods: []string{"POST", "PUT"}, 104 | respHeader: http.Header{ 105 | "Access-Control-Allow-Methods": []string{"POST, PUT, OPTIONS"}, 106 | }, 107 | status: http.StatusMethodNotAllowed, 108 | }, 109 | "accepted request method header": { 110 | request: optionsRequestWithHeader(http.Header{ 111 | "Access-Control-Request-Method": []string{"POST"}, 112 | }), 113 | methods: []string{"POST", "PUT"}, 114 | respHeader: http.Header{ 115 | "Access-Control-Allow-Methods": []string{"POST, PUT, OPTIONS"}, 116 | }, 117 | status: http.StatusOK, 118 | }, 119 | } 120 | for name, testCase := range testCases { 121 | t.Run(name, func(t *testing.T) { 122 | t.Parallel() 123 | 124 | writer := httptest.NewRecorder() 125 | 126 | AllowCORSMethods(testCase.request, writer, testCase.methods...) 127 | 128 | result := writer.Result() 129 | result.Body.Close() // for the linter 130 | assert.Equal(t, testCase.status, result.StatusCode) 131 | assert.Equal(t, testCase.respHeader, result.Header) 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/server/middlewares/cors/middleware.go: -------------------------------------------------------------------------------- 1 | package cors 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func New(allowedOriginsSlice, allowedHeaders []string) func(handler http.Handler) http.Handler { 8 | allowedOrigins := make(map[string]struct{}, len(allowedOriginsSlice)) 9 | for _, allowedOrigin := range allowedOriginsSlice { 10 | allowedOrigins[allowedOrigin] = struct{}{} 11 | } 12 | return func(handler http.Handler) http.Handler { 13 | return &corsHandler{ 14 | childHandler: handler, 15 | allowedOrigins: allowedOrigins, 16 | allowedHeaders: allowedHeaders, 17 | } 18 | } 19 | } 20 | 21 | type corsHandler struct { 22 | childHandler http.Handler 23 | allowedOrigins map[string]struct{} 24 | allowedHeaders []string 25 | } 26 | 27 | func (h *corsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | _ = setCrossOriginHeaders(r.Header, w.Header(), h.allowedOrigins, h.allowedHeaders) 29 | // if error is not nil, CORS headers are NOT set so the browser will fail. 30 | // We don't want to stop the handling because it could be a request from a server. 31 | h.childHandler.ServeHTTP(w, r) 32 | } 33 | -------------------------------------------------------------------------------- /internal/server/middlewares/cors/middleware_test.go: -------------------------------------------------------------------------------- 1 | package cors 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_corsHandler(t *testing.T) { 14 | t.Parallel() 15 | allowedOrigins := []string{"http://test"} 16 | allowedHeaders := []string{"Authorization"} 17 | middleware := New(allowedOrigins, allowedHeaders) 18 | 19 | childHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 20 | handler := middleware(childHandler) 21 | server := httptest.NewServer(handler) 22 | defer server.Close() 23 | 24 | ctx := context.Background() 25 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 26 | require.NoError(t, err) 27 | request.Header.Set("Origin", "http://test") 28 | 29 | client := server.Client() 30 | response, err := client.Do(request) 31 | require.NoError(t, err) 32 | _ = response.Body.Close() 33 | 34 | response.Header.Del("Date") 35 | 36 | expectedResponseHeader := http.Header{ 37 | "Access-Control-Allow-Origin": []string{"http://test"}, 38 | "Access-Control-Max-Age": []string{"14400"}, 39 | "Content-Length": []string{"0"}, 40 | "Access-Control-Allow-Headers": []string{"Authorization"}, 41 | } 42 | 43 | assert.Equal(t, expectedResponseHeader, response.Header) 44 | } 45 | -------------------------------------------------------------------------------- /internal/server/middlewares/log/clientip.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "net/netip" 7 | "strings" 8 | ) 9 | 10 | func extractClientIP(request *http.Request) netip.Addr { 11 | if request == nil { 12 | return netip.Addr{} 13 | } 14 | 15 | remoteAddress := removeAllSpaces(request.RemoteAddr) 16 | xRealIP := removeAllSpaces(request.Header.Get("X-Real-IP")) 17 | xForwardedFor := request.Header.Values("X-Forwarded-For") 18 | for i := range xForwardedFor { 19 | xForwardedFor[i] = removeAllSpaces(xForwardedFor[i]) 20 | } 21 | 22 | // No header so it can only be remoteAddress 23 | if xRealIP == "" && len(xForwardedFor) == 0 { 24 | ip, err := getIPFromHostPort(remoteAddress) 25 | if err == nil { 26 | return ip 27 | } 28 | return netip.Addr{} 29 | } 30 | 31 | // remoteAddress is the last proxy server forwarding the traffic 32 | // so we look into the HTTP headers to get the client IP 33 | xForwardedIPs := parseAllValidIPStrings(xForwardedFor) 34 | publicXForwardedIPs := extractPublicIPs(xForwardedIPs) 35 | if len(publicXForwardedIPs) > 0 { 36 | // first public XForwardedIP should be the client IP 37 | return publicXForwardedIPs[0] 38 | } 39 | 40 | // If all forwarded IP addresses are private we use the x-real-ip 41 | // address if it exists 42 | if xRealIP != "" { 43 | ip, err := getIPFromHostPort(xRealIP) 44 | if err == nil { 45 | return ip 46 | } 47 | } 48 | 49 | // Client IP is the first private IP address in the chain 50 | return xForwardedIPs[0] 51 | } 52 | 53 | func removeAllSpaces(header string) string { 54 | header = strings.ReplaceAll(header, " ", "") 55 | header = strings.ReplaceAll(header, "\t", "") 56 | return header 57 | } 58 | 59 | func extractPublicIPs(ips []netip.Addr) (publicIPs []netip.Addr) { 60 | publicIPs = make([]netip.Addr, 0, len(ips)) 61 | for _, ip := range ips { 62 | if ip.IsPrivate() { 63 | continue 64 | } 65 | publicIPs = append(publicIPs, ip) 66 | } 67 | return publicIPs 68 | } 69 | 70 | func parseAllValidIPStrings(stringIPs []string) (ips []netip.Addr) { 71 | ips = make([]netip.Addr, 0, len(stringIPs)) 72 | for _, s := range stringIPs { 73 | ip, err := netip.ParseAddr(s) 74 | if err == nil { 75 | ips = append(ips, ip) 76 | } 77 | } 78 | return ips 79 | } 80 | 81 | func getIPFromHostPort(address string) (ip netip.Addr, err error) { 82 | // address can be in the form ipv4:port, ipv6:port, ipv4 or ipv6 83 | ipString, _, err := splitHostPort(address) 84 | if err != nil { 85 | ipString = address 86 | } 87 | return netip.ParseAddr(ipString) 88 | } 89 | 90 | func splitHostPort(address string) (ip, port string, err error) { 91 | if strings.ContainsRune(address, '[') && strings.ContainsRune(address, ']') { 92 | // should be an IPv6 address with brackets 93 | return net.SplitHostPort(address) 94 | } 95 | const ipv4MaxColons = 1 96 | if strings.Count(address, ":") > ipv4MaxColons { 97 | // could be an IPv6 without brackets 98 | i := strings.LastIndex(address, ":") 99 | port = address[i+1:] 100 | ip = address[0:i] 101 | _, err = netip.ParseAddr(ip) 102 | if err != nil { 103 | return net.SplitHostPort(address) 104 | } 105 | return ip, port, nil 106 | } 107 | // IPv4 address 108 | return net.SplitHostPort(address) 109 | } 110 | -------------------------------------------------------------------------------- /internal/server/middlewares/log/clientip_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "net/http" 5 | "net/netip" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_extractClientIP(t *testing.T) { 12 | t.Parallel() 13 | 14 | makeHeader := func(keyValues map[string][]string) http.Header { 15 | header := http.Header{} 16 | for key, values := range keyValues { 17 | for _, value := range values { 18 | header.Add(key, value) 19 | } 20 | } 21 | return header 22 | } 23 | 24 | testCases := map[string]struct { 25 | r *http.Request 26 | ip netip.Addr 27 | }{ 28 | "nil request": {}, 29 | "empty request": { 30 | r: &http.Request{}, 31 | }, 32 | "request with remote address": { 33 | r: &http.Request{ 34 | RemoteAddr: "99.99.99.99", 35 | }, 36 | ip: netip.AddrFrom4([4]byte{99, 99, 99, 99}), 37 | }, 38 | "request with xRealIP header": { 39 | r: &http.Request{ 40 | RemoteAddr: "99.99.99.99", 41 | Header: makeHeader(map[string][]string{ 42 | "X-Real-IP": {"88.88.88.88"}, 43 | }), 44 | }, 45 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 46 | }, 47 | "request with xRealIP header and public XForwardedFor IP": { 48 | r: &http.Request{ 49 | RemoteAddr: "99.99.99.99", 50 | Header: makeHeader(map[string][]string{ 51 | "X-Real-IP": {"77.77.77.77"}, 52 | "X-Forwarded-For": {"88.88.88.88"}, 53 | }), 54 | }, 55 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 56 | }, 57 | "request with xRealIP header and private XForwardedFor IP": { 58 | r: &http.Request{ 59 | RemoteAddr: "99.99.99.99", 60 | Header: makeHeader(map[string][]string{ 61 | "X-Real-IP": {"88.88.88.88"}, 62 | "X-Forwarded-For": {"10.0.0.5"}, 63 | }), 64 | }, 65 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 66 | }, 67 | "request with single public IP in xForwardedFor header": { 68 | r: &http.Request{ 69 | RemoteAddr: "99.99.99.99", 70 | Header: makeHeader(map[string][]string{ 71 | "X-Forwarded-For": {"88.88.88.88"}, 72 | }), 73 | }, 74 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 75 | }, 76 | "request with two public IPs in xForwardedFor header": { 77 | r: &http.Request{ 78 | RemoteAddr: "99.99.99.99", 79 | Header: makeHeader(map[string][]string{ 80 | "X-Forwarded-For": {"88.88.88.88", "77.77.77.77"}, 81 | }), 82 | }, 83 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 84 | }, 85 | "request with private and public IPs in xForwardedFor header": { 86 | r: &http.Request{ 87 | RemoteAddr: "99.99.99.99", 88 | Header: makeHeader(map[string][]string{ 89 | "X-Forwarded-For": {"192.168.1.5", "88.88.88.88", "10.0.0.1", "77.77.77.77"}, 90 | }), 91 | }, 92 | ip: netip.AddrFrom4([4]byte{88, 88, 88, 88}), 93 | }, 94 | "request with single private IP in xForwardedFor header": { 95 | r: &http.Request{ 96 | RemoteAddr: "99.99.99.99", 97 | Header: makeHeader(map[string][]string{ 98 | "X-Forwarded-For": {"192.168.1.5"}, 99 | }), 100 | }, 101 | ip: netip.AddrFrom4([4]byte{192, 168, 1, 5}), 102 | }, 103 | "request with private IPs in xForwardedFor header": { 104 | r: &http.Request{ 105 | RemoteAddr: "99.99.99.99", 106 | Header: makeHeader(map[string][]string{ 107 | "X-Forwarded-For": {"192.168.1.5", "10.0.0.17"}, 108 | }), 109 | }, 110 | ip: netip.AddrFrom4([4]byte{192, 168, 1, 5}), 111 | }, 112 | } 113 | 114 | for name, testCase := range testCases { 115 | t.Run(name, func(t *testing.T) { 116 | t.Parallel() 117 | 118 | ip := extractClientIP(testCase.r) 119 | assert.Equal(t, testCase.ip, ip) 120 | }) 121 | } 122 | } 123 | 124 | func Test_splitHostPort(t *testing.T) { 125 | t.Parallel() 126 | 127 | testCases := map[string]struct { 128 | address string 129 | ip string 130 | port string 131 | errMessage string 132 | }{ 133 | "empty_address": { 134 | errMessage: "missing port in address", 135 | }, 136 | "invalid_address_with_brackets": { 137 | address: "[abc]", 138 | errMessage: "address [abc]: missing port in address", 139 | }, 140 | "address_with_brackets_without_port": { 141 | address: "[::1]", 142 | errMessage: "address [::1]: missing port in address", 143 | }, 144 | "address_with_brackets": { 145 | address: "[::1]:8000", 146 | ip: "::1", 147 | port: "8000", 148 | }, 149 | "malformed_ipv6_address_port": { 150 | address: "::x:", 151 | errMessage: "address ::x:: too many colons in address", 152 | }, 153 | "ipv6_address": { 154 | address: "::1:8000", 155 | ip: "::1", 156 | port: "8000", 157 | }, 158 | "ipv4_address": { 159 | address: "1.2.3.4:8000", 160 | ip: "1.2.3.4", 161 | port: "8000", 162 | }, 163 | } 164 | 165 | for name, testCase := range testCases { 166 | t.Run(name, func(t *testing.T) { 167 | t.Parallel() 168 | 169 | ip, port, err := splitHostPort(testCase.address) 170 | assert.Equal(t, testCase.ip, ip) 171 | assert.Equal(t, testCase.port, port) 172 | if testCase.errMessage != "" { 173 | assert.EqualError(t, err, testCase.errMessage) 174 | } else { 175 | assert.NoError(t, err) 176 | } 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /internal/server/middlewares/log/interfaces.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | type Logger interface { 4 | Infof(format string, args ...any) 5 | } 6 | -------------------------------------------------------------------------------- /internal/server/middlewares/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | func New(logger Logger) func(http.Handler) http.Handler { 11 | return func(handler http.Handler) http.Handler { 12 | return &logHandler{ 13 | childHandler: handler, 14 | logger: logger, 15 | timeNow: time.Now, 16 | } 17 | } 18 | } 19 | 20 | type logHandler struct { 21 | childHandler http.Handler 22 | logger Logger 23 | timeNow func() time.Time 24 | } 25 | 26 | func (h *logHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 27 | startTime := h.timeNow() 28 | customWriter := &statefulWriter{ResponseWriter: w} 29 | h.childHandler.ServeHTTP(customWriter, r) 30 | clientIP := extractClientIP(r) 31 | bytesWritten := byteCountSI(customWriter.length) 32 | const durationResolution = time.Millisecond 33 | handlingDuration := h.timeNow().Sub(startTime).Round(durationResolution) / durationResolution 34 | h.logger.Infof("HTTP request: %d %s %s %s %s %dms", 35 | customWriter.status, r.Method, r.RequestURI, clientIP, 36 | bytesWritten, handlingDuration) 37 | } 38 | 39 | func byteCountSI(b int) string { 40 | const unit = 1000 41 | if b < unit { 42 | const base = 10 43 | return strconv.FormatInt(int64(b), base) + "B" 44 | } 45 | div := unit 46 | var exp uint 47 | for n := b / unit; n >= unit; n /= unit { 48 | div *= unit 49 | exp++ 50 | } 51 | return fmt.Sprintf("%.1f%cB", 52 | float64(b)/float64(div), "kMGTPE"[exp]) 53 | } 54 | -------------------------------------------------------------------------------- /internal/server/middlewares/log/writer.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "net/http" 4 | 5 | // statefulWriter wraps the HTTP writer in order to report 6 | // the HTTP status code and the number of bytes written. 7 | type statefulWriter struct { 8 | http.ResponseWriter 9 | status int 10 | length int 11 | } 12 | 13 | func (w *statefulWriter) WriteHeader(status int) { 14 | w.status = status 15 | w.ResponseWriter.WriteHeader(status) 16 | } 17 | 18 | func (w *statefulWriter) Write(b []byte) (n int, err error) { 19 | if w.status == 0 { 20 | w.status = http.StatusOK 21 | } 22 | n, err = w.ResponseWriter.Write(b) 23 | w.length += n 24 | return n, err 25 | } 26 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/interfaces.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "time" 4 | 5 | type Metrics interface { 6 | RequestCountInc(routePattern string, statusCode int) 7 | ResponseBytesCountAdd(routePattern string, statusCode int, bytesWritten int) 8 | InflightRequestsGaugeAdd(addition int) 9 | ResponseTimeHistogramObserve(routePattern string, statusCode int, duration time.Duration) 10 | } 11 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | // Package metrics implements a metrics middleware for an HTTP server 2 | // that records metrics data for Prometheus. 3 | package metrics 4 | 5 | import ( 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | func New(metrics Metrics) func(http.Handler) http.Handler { 14 | return func(handler http.Handler) http.Handler { 15 | return &metricsHandler{ 16 | childHandler: handler, 17 | metrics: metrics, 18 | timeNow: time.Now, 19 | } 20 | } 21 | } 22 | 23 | type metricsHandler struct { 24 | childHandler http.Handler 25 | metrics Metrics 26 | timeNow func() time.Time // for mocks 27 | } 28 | 29 | func (h *metricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 30 | startTime := h.timeNow() 31 | 32 | h.metrics.InflightRequestsGaugeAdd(1) 33 | defer h.metrics.InflightRequestsGaugeAdd(-1) 34 | 35 | statefulWriter := &statefulWriter{ResponseWriter: w} 36 | 37 | h.childHandler.ServeHTTP(statefulWriter, r) 38 | 39 | chiCtx := chi.RouteContext(r.Context()) 40 | routePattern := chiCtx.RoutePattern() 41 | if routePattern == "" { 42 | routePattern = "unrecognized" 43 | } 44 | routePattern = strings.TrimSuffix(routePattern, "/") 45 | 46 | duration := h.timeNow().Sub(startTime) 47 | 48 | h.metrics.RequestCountInc(routePattern, statefulWriter.status) 49 | h.metrics.ResponseBytesCountAdd(routePattern, statefulWriter.status, statefulWriter.length) 50 | h.metrics.ResponseTimeHistogramObserve(routePattern, statefulWriter.status, duration) 51 | } 52 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/golang/mock/gomock" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func Test_metricsMiddleware(t *testing.T) { 18 | t.Parallel() 19 | ctrl := gomock.NewController(t) 20 | 21 | const responseStatus = http.StatusBadRequest 22 | responseBody := []byte{1, 2, 3, 4} 23 | req := httptest.NewRequest(http.MethodGet, "https://test.com", nil) 24 | const routePattern = "/test" 25 | chiCtx := chi.NewRouteContext() 26 | chiCtx.RoutePatterns = []string{routePattern} 27 | ctx := context.WithValue(req.Context(), chi.RouteCtxKey, chiCtx) 28 | req = req.Clone(ctx) 29 | 30 | childHandler := http.HandlerFunc( 31 | func(w http.ResponseWriter, r *http.Request) { 32 | assert.Equal(t, req.Method, r.Method) 33 | assert.Equal(t, req.URL, r.URL) 34 | w.WriteHeader(responseStatus) 35 | _, _ = w.Write(responseBody) 36 | }, 37 | ) 38 | 39 | timeNowIndex := 0 40 | timeNow := func() time.Time { 41 | unix := int64(4156132 + timeNowIndex) 42 | timeNowIndex++ 43 | return time.Unix(unix, 0) 44 | } 45 | 46 | metrics := NewMockMetrics(ctrl) 47 | metrics.EXPECT().InflightRequestsGaugeAdd(1) 48 | metrics.EXPECT().InflightRequestsGaugeAdd(-1) 49 | metrics.EXPECT().RequestCountInc(routePattern, responseStatus) 50 | metrics.EXPECT().ResponseBytesCountAdd(routePattern, responseStatus, len(responseBody)) 51 | metrics.EXPECT().ResponseTimeHistogramObserve(routePattern, responseStatus, time.Second) 52 | 53 | handler := &metricsHandler{ 54 | childHandler: childHandler, 55 | metrics: metrics, 56 | timeNow: timeNow, 57 | } 58 | 59 | w := httptest.NewRecorder() 60 | 61 | handler.ServeHTTP(w, req) 62 | 63 | result := w.Result() 64 | b, err := io.ReadAll(result.Body) 65 | require.NoError(t, err) 66 | defer result.Body.Close() 67 | assert.Equal(t, responseStatus, result.StatusCode) 68 | assert.Equal(t, responseBody, b) 69 | } 70 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/mocks_generate_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | //go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Metrics 4 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/mocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/qdm12/go-template/internal/server/middlewares/metrics (interfaces: Metrics) 3 | 4 | // Package metrics is a generated GoMock package. 5 | package metrics 6 | 7 | import ( 8 | reflect "reflect" 9 | time "time" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockMetrics is a mock of Metrics interface. 15 | type MockMetrics struct { 16 | ctrl *gomock.Controller 17 | recorder *MockMetricsMockRecorder 18 | } 19 | 20 | // MockMetricsMockRecorder is the mock recorder for MockMetrics. 21 | type MockMetricsMockRecorder struct { 22 | mock *MockMetrics 23 | } 24 | 25 | // NewMockMetrics creates a new mock instance. 26 | func NewMockMetrics(ctrl *gomock.Controller) *MockMetrics { 27 | mock := &MockMetrics{ctrl: ctrl} 28 | mock.recorder = &MockMetricsMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockMetrics) EXPECT() *MockMetricsMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // InflightRequestsGaugeAdd mocks base method. 38 | func (m *MockMetrics) InflightRequestsGaugeAdd(arg0 int) { 39 | m.ctrl.T.Helper() 40 | m.ctrl.Call(m, "InflightRequestsGaugeAdd", arg0) 41 | } 42 | 43 | // InflightRequestsGaugeAdd indicates an expected call of InflightRequestsGaugeAdd. 44 | func (mr *MockMetricsMockRecorder) InflightRequestsGaugeAdd(arg0 interface{}) *gomock.Call { 45 | mr.mock.ctrl.T.Helper() 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InflightRequestsGaugeAdd", reflect.TypeOf((*MockMetrics)(nil).InflightRequestsGaugeAdd), arg0) 47 | } 48 | 49 | // RequestCountInc mocks base method. 50 | func (m *MockMetrics) RequestCountInc(arg0 string, arg1 int) { 51 | m.ctrl.T.Helper() 52 | m.ctrl.Call(m, "RequestCountInc", arg0, arg1) 53 | } 54 | 55 | // RequestCountInc indicates an expected call of RequestCountInc. 56 | func (mr *MockMetricsMockRecorder) RequestCountInc(arg0, arg1 interface{}) *gomock.Call { 57 | mr.mock.ctrl.T.Helper() 58 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCountInc", reflect.TypeOf((*MockMetrics)(nil).RequestCountInc), arg0, arg1) 59 | } 60 | 61 | // ResponseBytesCountAdd mocks base method. 62 | func (m *MockMetrics) ResponseBytesCountAdd(arg0 string, arg1, arg2 int) { 63 | m.ctrl.T.Helper() 64 | m.ctrl.Call(m, "ResponseBytesCountAdd", arg0, arg1, arg2) 65 | } 66 | 67 | // ResponseBytesCountAdd indicates an expected call of ResponseBytesCountAdd. 68 | func (mr *MockMetricsMockRecorder) ResponseBytesCountAdd(arg0, arg1, arg2 interface{}) *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResponseBytesCountAdd", reflect.TypeOf((*MockMetrics)(nil).ResponseBytesCountAdd), arg0, arg1, arg2) 71 | } 72 | 73 | // ResponseTimeHistogramObserve mocks base method. 74 | func (m *MockMetrics) ResponseTimeHistogramObserve(arg0 string, arg1 int, arg2 time.Duration) { 75 | m.ctrl.T.Helper() 76 | m.ctrl.Call(m, "ResponseTimeHistogramObserve", arg0, arg1, arg2) 77 | } 78 | 79 | // ResponseTimeHistogramObserve indicates an expected call of ResponseTimeHistogramObserve. 80 | func (mr *MockMetricsMockRecorder) ResponseTimeHistogramObserve(arg0, arg1, arg2 interface{}) *gomock.Call { 81 | mr.mock.ctrl.T.Helper() 82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResponseTimeHistogramObserve", reflect.TypeOf((*MockMetrics)(nil).ResponseTimeHistogramObserve), arg0, arg1, arg2) 83 | } 84 | -------------------------------------------------------------------------------- /internal/server/middlewares/metrics/writer.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "net/http" 4 | 5 | // statefulWriter wraps the HTTP writer in order to report 6 | // the HTTP status code and the number of bytes written. 7 | type statefulWriter struct { 8 | http.ResponseWriter 9 | status int 10 | length int 11 | } 12 | 13 | func (w *statefulWriter) WriteHeader(status int) { 14 | w.status = status 15 | w.ResponseWriter.WriteHeader(status) 16 | } 17 | 18 | func (w *statefulWriter) Write(b []byte) (n int, err error) { 19 | if w.status == 0 { 20 | w.status = http.StatusOK 21 | } 22 | n, err = w.ResponseWriter.Write(b) 23 | w.length += n 24 | return n, err 25 | } 26 | -------------------------------------------------------------------------------- /internal/server/mocks_generate_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | //go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Logger,Metrics,Processor 4 | -------------------------------------------------------------------------------- /internal/server/mocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/qdm12/go-template/internal/server (interfaces: Logger,Metrics,Processor) 3 | 4 | // Package server is a generated GoMock package. 5 | package server 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | time "time" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | models "github.com/qdm12/go-template/internal/models" 14 | ) 15 | 16 | // MockLogger is a mock of Logger interface. 17 | type MockLogger struct { 18 | ctrl *gomock.Controller 19 | recorder *MockLoggerMockRecorder 20 | } 21 | 22 | // MockLoggerMockRecorder is the mock recorder for MockLogger. 23 | type MockLoggerMockRecorder struct { 24 | mock *MockLogger 25 | } 26 | 27 | // NewMockLogger creates a new mock instance. 28 | func NewMockLogger(ctrl *gomock.Controller) *MockLogger { 29 | mock := &MockLogger{ctrl: ctrl} 30 | mock.recorder = &MockLoggerMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // Debugf mocks base method. 40 | func (m *MockLogger) Debugf(arg0 string, arg1 ...interface{}) { 41 | m.ctrl.T.Helper() 42 | varargs := []interface{}{arg0} 43 | for _, a := range arg1 { 44 | varargs = append(varargs, a) 45 | } 46 | m.ctrl.Call(m, "Debugf", varargs...) 47 | } 48 | 49 | // Debugf indicates an expected call of Debugf. 50 | func (mr *MockLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { 51 | mr.mock.ctrl.T.Helper() 52 | varargs := append([]interface{}{arg0}, arg1...) 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) 54 | } 55 | 56 | // Error mocks base method. 57 | func (m *MockLogger) Error(arg0 string) { 58 | m.ctrl.T.Helper() 59 | m.ctrl.Call(m, "Error", arg0) 60 | } 61 | 62 | // Error indicates an expected call of Error. 63 | func (mr *MockLoggerMockRecorder) Error(arg0 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0) 66 | } 67 | 68 | // Infof mocks base method. 69 | func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) { 70 | m.ctrl.T.Helper() 71 | varargs := []interface{}{arg0} 72 | for _, a := range arg1 { 73 | varargs = append(varargs, a) 74 | } 75 | m.ctrl.Call(m, "Infof", varargs...) 76 | } 77 | 78 | // Infof indicates an expected call of Infof. 79 | func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call { 80 | mr.mock.ctrl.T.Helper() 81 | varargs := append([]interface{}{arg0}, arg1...) 82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) 83 | } 84 | 85 | // MockMetrics is a mock of Metrics interface. 86 | type MockMetrics struct { 87 | ctrl *gomock.Controller 88 | recorder *MockMetricsMockRecorder 89 | } 90 | 91 | // MockMetricsMockRecorder is the mock recorder for MockMetrics. 92 | type MockMetricsMockRecorder struct { 93 | mock *MockMetrics 94 | } 95 | 96 | // NewMockMetrics creates a new mock instance. 97 | func NewMockMetrics(ctrl *gomock.Controller) *MockMetrics { 98 | mock := &MockMetrics{ctrl: ctrl} 99 | mock.recorder = &MockMetricsMockRecorder{mock} 100 | return mock 101 | } 102 | 103 | // EXPECT returns an object that allows the caller to indicate expected use. 104 | func (m *MockMetrics) EXPECT() *MockMetricsMockRecorder { 105 | return m.recorder 106 | } 107 | 108 | // InflightRequestsGaugeAdd mocks base method. 109 | func (m *MockMetrics) InflightRequestsGaugeAdd(arg0 int) { 110 | m.ctrl.T.Helper() 111 | m.ctrl.Call(m, "InflightRequestsGaugeAdd", arg0) 112 | } 113 | 114 | // InflightRequestsGaugeAdd indicates an expected call of InflightRequestsGaugeAdd. 115 | func (mr *MockMetricsMockRecorder) InflightRequestsGaugeAdd(arg0 interface{}) *gomock.Call { 116 | mr.mock.ctrl.T.Helper() 117 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InflightRequestsGaugeAdd", reflect.TypeOf((*MockMetrics)(nil).InflightRequestsGaugeAdd), arg0) 118 | } 119 | 120 | // RequestCountInc mocks base method. 121 | func (m *MockMetrics) RequestCountInc(arg0 string, arg1 int) { 122 | m.ctrl.T.Helper() 123 | m.ctrl.Call(m, "RequestCountInc", arg0, arg1) 124 | } 125 | 126 | // RequestCountInc indicates an expected call of RequestCountInc. 127 | func (mr *MockMetricsMockRecorder) RequestCountInc(arg0, arg1 interface{}) *gomock.Call { 128 | mr.mock.ctrl.T.Helper() 129 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestCountInc", reflect.TypeOf((*MockMetrics)(nil).RequestCountInc), arg0, arg1) 130 | } 131 | 132 | // ResponseBytesCountAdd mocks base method. 133 | func (m *MockMetrics) ResponseBytesCountAdd(arg0 string, arg1, arg2 int) { 134 | m.ctrl.T.Helper() 135 | m.ctrl.Call(m, "ResponseBytesCountAdd", arg0, arg1, arg2) 136 | } 137 | 138 | // ResponseBytesCountAdd indicates an expected call of ResponseBytesCountAdd. 139 | func (mr *MockMetricsMockRecorder) ResponseBytesCountAdd(arg0, arg1, arg2 interface{}) *gomock.Call { 140 | mr.mock.ctrl.T.Helper() 141 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResponseBytesCountAdd", reflect.TypeOf((*MockMetrics)(nil).ResponseBytesCountAdd), arg0, arg1, arg2) 142 | } 143 | 144 | // ResponseTimeHistogramObserve mocks base method. 145 | func (m *MockMetrics) ResponseTimeHistogramObserve(arg0 string, arg1 int, arg2 time.Duration) { 146 | m.ctrl.T.Helper() 147 | m.ctrl.Call(m, "ResponseTimeHistogramObserve", arg0, arg1, arg2) 148 | } 149 | 150 | // ResponseTimeHistogramObserve indicates an expected call of ResponseTimeHistogramObserve. 151 | func (mr *MockMetricsMockRecorder) ResponseTimeHistogramObserve(arg0, arg1, arg2 interface{}) *gomock.Call { 152 | mr.mock.ctrl.T.Helper() 153 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResponseTimeHistogramObserve", reflect.TypeOf((*MockMetrics)(nil).ResponseTimeHistogramObserve), arg0, arg1, arg2) 154 | } 155 | 156 | // MockProcessor is a mock of Processor interface. 157 | type MockProcessor struct { 158 | ctrl *gomock.Controller 159 | recorder *MockProcessorMockRecorder 160 | } 161 | 162 | // MockProcessorMockRecorder is the mock recorder for MockProcessor. 163 | type MockProcessorMockRecorder struct { 164 | mock *MockProcessor 165 | } 166 | 167 | // NewMockProcessor creates a new mock instance. 168 | func NewMockProcessor(ctrl *gomock.Controller) *MockProcessor { 169 | mock := &MockProcessor{ctrl: ctrl} 170 | mock.recorder = &MockProcessorMockRecorder{mock} 171 | return mock 172 | } 173 | 174 | // EXPECT returns an object that allows the caller to indicate expected use. 175 | func (m *MockProcessor) EXPECT() *MockProcessorMockRecorder { 176 | return m.recorder 177 | } 178 | 179 | // CreateUser mocks base method. 180 | func (m *MockProcessor) CreateUser(arg0 context.Context, arg1 models.User) error { 181 | m.ctrl.T.Helper() 182 | ret := m.ctrl.Call(m, "CreateUser", arg0, arg1) 183 | ret0, _ := ret[0].(error) 184 | return ret0 185 | } 186 | 187 | // CreateUser indicates an expected call of CreateUser. 188 | func (mr *MockProcessorMockRecorder) CreateUser(arg0, arg1 interface{}) *gomock.Call { 189 | mr.mock.ctrl.T.Helper() 190 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockProcessor)(nil).CreateUser), arg0, arg1) 191 | } 192 | 193 | // GetUserByID mocks base method. 194 | func (m *MockProcessor) GetUserByID(arg0 context.Context, arg1 uint64) (models.User, error) { 195 | m.ctrl.T.Helper() 196 | ret := m.ctrl.Call(m, "GetUserByID", arg0, arg1) 197 | ret0, _ := ret[0].(models.User) 198 | ret1, _ := ret[1].(error) 199 | return ret0, ret1 200 | } 201 | 202 | // GetUserByID indicates an expected call of GetUserByID. 203 | func (mr *MockProcessorMockRecorder) GetUserByID(arg0, arg1 interface{}) *gomock.Call { 204 | mr.mock.ctrl.T.Helper() 205 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockProcessor)(nil).GetUserByID), arg0, arg1) 206 | } 207 | -------------------------------------------------------------------------------- /internal/server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/qdm12/go-template/internal/config/settings" 9 | "github.com/qdm12/go-template/internal/models" 10 | "github.com/qdm12/go-template/internal/server/middlewares/cors" 11 | logmware "github.com/qdm12/go-template/internal/server/middlewares/log" 12 | metricsmware "github.com/qdm12/go-template/internal/server/middlewares/metrics" 13 | "github.com/qdm12/go-template/internal/server/routes/build" 14 | "github.com/qdm12/go-template/internal/server/routes/users" 15 | ) 16 | 17 | func NewRouter(config settings.HTTP, logger Logger, 18 | metrics Metrics, buildInfo models.BuildInformation, 19 | proc Processor, 20 | ) *chi.Mux { 21 | router := chi.NewRouter() 22 | 23 | var middlewares []func(http.Handler) http.Handler 24 | metricsMiddleware := metricsmware.New(metrics) 25 | middlewares = append(middlewares, metricsMiddleware) 26 | if *config.LogRequests { 27 | logMiddleware := logmware.New(logger) 28 | middlewares = append(middlewares, logMiddleware) 29 | } 30 | corsMiddleware := cors.New(config.AllowedOrigins, config.AllowedHeaders) 31 | middlewares = append(middlewares, corsMiddleware) 32 | router.Use(middlewares...) 33 | 34 | APIPrefix := *config.RootURL 35 | for strings.HasSuffix(APIPrefix, "/") { 36 | APIPrefix = strings.TrimSuffix(APIPrefix, "/") 37 | } 38 | APIPrefix += "/api/v1" 39 | 40 | router.Mount(APIPrefix+"/users", users.NewHandler(logger, proc)) 41 | router.Mount(APIPrefix+"/build", build.NewHandler(logger, buildInfo)) 42 | 43 | return router 44 | } 45 | -------------------------------------------------------------------------------- /internal/server/router_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/netip" 10 | "net/url" 11 | "testing" 12 | "time" 13 | 14 | "github.com/golang/mock/gomock" 15 | "github.com/qdm12/go-template/internal/config/settings" 16 | "github.com/qdm12/go-template/internal/models" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | func ptrTo[T any](x T) *T { return &x } 22 | 23 | func Test_Router(t *testing.T) { 24 | t.Parallel() 25 | 26 | testCases := map[string]struct { 27 | config settings.HTTP 28 | makeLogger func(ctrl *gomock.Controller) *MockLogger 29 | makeMetrics func(ctrl *gomock.Controller) *MockMetrics 30 | makeProcessor func(ctrl *gomock.Controller) *MockProcessor 31 | buildInfo models.BuildInformation 32 | path string 33 | method string 34 | requestBody string 35 | requestHeader http.Header 36 | expectedStatus int 37 | expectedHeader http.Header 38 | expectedBody string 39 | }{ 40 | "get_build": { 41 | config: settings.HTTP{ 42 | RootURL: ptrTo("/rooturl"), 43 | LogRequests: ptrTo(true), 44 | }, 45 | makeLogger: func(ctrl *gomock.Controller) *MockLogger { 46 | logger := NewMockLogger(ctrl) 47 | logger.EXPECT().Infof("HTTP request: %d %s %s %s %s %dms", 48 | http.StatusOK, http.MethodGet, "/rooturl/api/v1/build", 49 | netip.AddrFrom4([4]byte{127, 0, 0, 1}), "73B", 50 | gomock.AssignableToTypeOf(time.Second)) 51 | return logger 52 | }, 53 | makeMetrics: func(ctrl *gomock.Controller) *MockMetrics { 54 | metrics := NewMockMetrics(ctrl) 55 | metrics.EXPECT().InflightRequestsGaugeAdd(1) 56 | metrics.EXPECT().InflightRequestsGaugeAdd(-1) 57 | metrics.EXPECT().RequestCountInc("/rooturl/api/v1/build", http.StatusOK) 58 | metrics.EXPECT().ResponseBytesCountAdd("/rooturl/api/v1/build", 59 | http.StatusOK, 73) 60 | metrics.EXPECT().ResponseTimeHistogramObserve("/rooturl/api/v1/build", 61 | http.StatusOK, gomock.AssignableToTypeOf(time.Second)) 62 | return metrics 63 | }, 64 | makeProcessor: func(_ *gomock.Controller) *MockProcessor { 65 | return nil 66 | }, 67 | buildInfo: models.BuildInformation{ 68 | Version: "1.2.3", 69 | Commit: "abcdef", 70 | BuildDate: "2023-05-26T00:00:00Z", 71 | }, 72 | path: "/rooturl/api/v1/build", 73 | method: http.MethodGet, 74 | expectedStatus: http.StatusOK, 75 | expectedHeader: http.Header{ 76 | "Content-Length": []string{"73"}, 77 | "Content-Type": []string{"application/json"}, 78 | }, 79 | expectedBody: `{"version":"1.2.3","commit":"abcdef",` + 80 | `"buildDate":"2023-05-26T00:00:00Z"}` + "\n", 81 | }, 82 | "create_user": { 83 | config: settings.HTTP{ 84 | RootURL: ptrTo("/"), 85 | LogRequests: ptrTo(true), 86 | }, 87 | makeLogger: func(ctrl *gomock.Controller) *MockLogger { 88 | logger := NewMockLogger(ctrl) 89 | logger.EXPECT().Infof("HTTP request: %d %s %s %s %s %dms", 90 | http.StatusCreated, http.MethodPost, "/api/v1/users", 91 | netip.AddrFrom4([4]byte{127, 0, 0, 1}), "0B", 92 | gomock.AssignableToTypeOf(time.Second)) 93 | return logger 94 | }, 95 | makeMetrics: func(ctrl *gomock.Controller) *MockMetrics { 96 | metrics := NewMockMetrics(ctrl) 97 | metrics.EXPECT().InflightRequestsGaugeAdd(1) 98 | metrics.EXPECT().InflightRequestsGaugeAdd(-1) 99 | metrics.EXPECT().RequestCountInc("/api/v1/users", http.StatusCreated) 100 | metrics.EXPECT().ResponseBytesCountAdd("/api/v1/users", 101 | http.StatusCreated, 0) 102 | metrics.EXPECT().ResponseTimeHistogramObserve("/api/v1/users", 103 | http.StatusCreated, gomock.AssignableToTypeOf(time.Second)) 104 | return metrics 105 | }, 106 | makeProcessor: func(ctrl *gomock.Controller) *MockProcessor { 107 | processor := NewMockProcessor(ctrl) 108 | expectedUser := models.User{ 109 | ID: 1, 110 | Account: "admin", 111 | Username: "qdm12", 112 | } 113 | processor.EXPECT().CreateUser(gomock.Any(), expectedUser). 114 | Return(nil) 115 | return processor 116 | }, 117 | path: "/api/v1/users", 118 | method: http.MethodPost, 119 | requestBody: `{"id": 1, "account": "admin", "username": "qdm12"}`, 120 | expectedStatus: http.StatusCreated, 121 | expectedHeader: http.Header{ 122 | "Content-Length": []string{"0"}, 123 | "Content-Type": []string{"application/json"}, 124 | }, 125 | }, 126 | } 127 | 128 | for name, testCase := range testCases { 129 | t.Run(name, func(t *testing.T) { 130 | t.Parallel() 131 | ctrl := gomock.NewController(t) 132 | 133 | logger := testCase.makeLogger(ctrl) 134 | metrics := testCase.makeMetrics(ctrl) 135 | processor := testCase.makeProcessor(ctrl) 136 | router := NewRouter(testCase.config, logger, metrics, 137 | testCase.buildInfo, processor) 138 | 139 | server := httptest.NewServer(router) 140 | t.Cleanup(server.Close) 141 | client := server.Client() 142 | 143 | ctx := context.Background() 144 | testDeadline, ok := t.Deadline() 145 | if ok { 146 | var cancel context.CancelFunc 147 | ctx, cancel = context.WithDeadline(context.Background(), testDeadline) 148 | defer cancel() 149 | } 150 | 151 | body := bytes.NewBufferString(testCase.requestBody) 152 | url, err := url.Parse(server.URL) 153 | require.NoError(t, err) 154 | url.Path = testCase.path 155 | request, err := http.NewRequestWithContext(ctx, 156 | testCase.method, url.String(), body) 157 | require.NoError(t, err) 158 | for key, values := range testCase.requestHeader { 159 | for _, value := range values { 160 | request.Header.Add(key, value) 161 | } 162 | } 163 | 164 | response, err := client.Do(request) 165 | require.NoError(t, err) 166 | t.Cleanup(func() { 167 | _ = response.Body.Close() 168 | }) 169 | 170 | for expectedKey, expectedValues := range testCase.expectedHeader { 171 | values := response.Header.Values(expectedKey) 172 | assert.Equal(t, expectedValues, values) 173 | } 174 | assert.Equal(t, testCase.expectedStatus, response.StatusCode) 175 | responseData, err := io.ReadAll(response.Body) 176 | require.NoError(t, err) 177 | assert.Equal(t, testCase.expectedBody, string(responseData)) 178 | 179 | err = response.Body.Close() 180 | require.NoError(t, err) 181 | }) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /internal/server/routes/build/getinfo.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/qdm12/go-template/internal/server/contenttype" 8 | "github.com/qdm12/go-template/internal/server/httperr" 9 | ) 10 | 11 | // Handler to get the program build information (GET /). 12 | func (h *handler) getBuild(w http.ResponseWriter, r *http.Request) { 13 | _, responseContentType, err := contenttype.APICheck(r.Header) 14 | w.Header().Set("Content-Type", responseContentType) 15 | errResponder := httperr.NewResponder(responseContentType, h.logger) 16 | 17 | if err != nil { 18 | errResponder.Respond(w, http.StatusNotAcceptable, err.Error()) 19 | return 20 | } 21 | 22 | err = json.NewEncoder(w).Encode(h.build) 23 | if err != nil { 24 | h.logger.Error(err.Error()) 25 | errResponder.Respond(w, http.StatusInternalServerError, "") 26 | return 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/server/routes/build/getinfo_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/qdm12/go-template/internal/models" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_handler_getBuild(t *testing.T) { 15 | t.Parallel() 16 | 17 | testCases := map[string]struct { 18 | handler *handler 19 | buildRequest func() *http.Request 20 | expectedStatus int 21 | expectedBody string 22 | }{ 23 | "no_response_content_type_supported": { 24 | handler: &handler{}, 25 | buildRequest: func() *http.Request { 26 | request := httptest.NewRequest(http.MethodGet, "http://test.com", nil) 27 | request.Header.Set("Accept", "blah") 28 | return request 29 | }, 30 | expectedStatus: http.StatusNotAcceptable, 31 | expectedBody: `{"error":"no response content type supported: blah"}` + "\n", 32 | }, 33 | "success": { 34 | handler: &handler{ 35 | build: models.BuildInformation{ 36 | Version: "1.2.3", 37 | Commit: "abcdef", 38 | BuildDate: "2023-05-26T00:00:00Z", 39 | }, 40 | }, 41 | buildRequest: func() *http.Request { 42 | return httptest.NewRequest(http.MethodGet, "http://test.com", nil) 43 | }, 44 | expectedStatus: http.StatusOK, 45 | expectedBody: `{"version":"1.2.3","commit":"abcdef","buildDate":"2023-05-26T00:00:00Z"}` + "\n", 46 | }, 47 | } 48 | 49 | for name, testCase := range testCases { 50 | t.Run(name, func(t *testing.T) { 51 | t.Parallel() 52 | 53 | writer := httptest.NewRecorder() 54 | request := testCase.buildRequest() 55 | 56 | testCase.handler.getBuild(writer, request) 57 | 58 | result := writer.Result() 59 | body, err := io.ReadAll(result.Body) 60 | closeErr := result.Body.Close() 61 | require.NoError(t, err) 62 | assert.NoError(t, closeErr) 63 | 64 | assert.Equal(t, testCase.expectedStatus, result.StatusCode) 65 | assert.Equal(t, testCase.expectedBody, string(body)) 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/server/routes/build/handler.go: -------------------------------------------------------------------------------- 1 | // Package build is the HTTP handler for the build information. 2 | package build 3 | 4 | import ( 5 | "github.com/go-chi/chi/v5" 6 | "github.com/qdm12/go-template/internal/models" 7 | ) 8 | 9 | type handler struct { 10 | logger Logger 11 | build models.BuildInformation 12 | } 13 | 14 | func NewHandler(logger Logger, buildInfo models.BuildInformation) *chi.Mux { 15 | h := &handler{ 16 | logger: logger, 17 | build: buildInfo, 18 | } 19 | router := chi.NewRouter() 20 | router.Get("/", h.getBuild) 21 | router.Options("/", h.options) 22 | return router 23 | } 24 | -------------------------------------------------------------------------------- /internal/server/routes/build/handler_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/golang/mock/gomock" 11 | "github.com/qdm12/go-template/internal/models" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func Test_handler(t *testing.T) { 17 | t.Parallel() 18 | 19 | testCases := map[string]struct { 20 | makeLogger func(ctrl *gomock.Controller) *MockLogger 21 | buildInfo models.BuildInformation 22 | method string 23 | requestHeader http.Header 24 | expectedStatus int 25 | expectedHeader http.Header 26 | expectedBody string 27 | }{ 28 | "get_build": { 29 | makeLogger: func(_ *gomock.Controller) *MockLogger { 30 | return nil 31 | }, 32 | buildInfo: models.BuildInformation{ 33 | Version: "1.2.3", 34 | Commit: "abcdef", 35 | BuildDate: "2023-05-26T00:00:00Z", 36 | }, 37 | method: http.MethodGet, 38 | expectedStatus: http.StatusOK, 39 | expectedHeader: http.Header{ 40 | "Content-Length": []string{"73"}, 41 | "Content-Type": []string{"application/json"}, 42 | }, 43 | expectedBody: `{"version":"1.2.3","commit":"abcdef",` + 44 | `"buildDate":"2023-05-26T00:00:00Z"}` + "\n", 45 | }, 46 | "options": { 47 | makeLogger: func(_ *gomock.Controller) *MockLogger { 48 | return nil 49 | }, 50 | buildInfo: models.BuildInformation{ 51 | Version: "1.2.3", 52 | Commit: "abcdef", 53 | BuildDate: "2023-05-26T00:00:00Z", 54 | }, 55 | method: http.MethodOptions, 56 | requestHeader: http.Header{ 57 | "Access-Control-Request-Method": []string{http.MethodOptions}, 58 | }, 59 | expectedStatus: http.StatusOK, 60 | expectedHeader: http.Header{ 61 | "Access-Control-Allow-Methods": []string{http.MethodGet + ", " + http.MethodOptions}, 62 | "Content-Length": []string{"0"}, 63 | }, 64 | }, 65 | } 66 | 67 | for name, testCase := range testCases { 68 | t.Run(name, func(t *testing.T) { 69 | t.Parallel() 70 | ctrl := gomock.NewController(t) 71 | 72 | logger := testCase.makeLogger(ctrl) 73 | handler := NewHandler(logger, testCase.buildInfo) 74 | 75 | server := httptest.NewServer(handler) 76 | t.Cleanup(server.Close) 77 | client := server.Client() 78 | 79 | ctx := context.Background() 80 | testDeadline, ok := t.Deadline() 81 | if ok { 82 | var cancel context.CancelFunc 83 | ctx, cancel = context.WithDeadline(context.Background(), testDeadline) 84 | defer cancel() 85 | } 86 | 87 | request, err := http.NewRequestWithContext(ctx, 88 | testCase.method, server.URL, nil) 89 | require.NoError(t, err) 90 | for key, values := range testCase.requestHeader { 91 | for _, value := range values { 92 | request.Header.Add(key, value) 93 | } 94 | } 95 | 96 | response, err := client.Do(request) 97 | require.NoError(t, err) 98 | t.Cleanup(func() { 99 | _ = response.Body.Close() 100 | }) 101 | 102 | for expectedKey, expectedValues := range testCase.expectedHeader { 103 | values := response.Header.Values(expectedKey) 104 | assert.Equal(t, expectedValues, values) 105 | } 106 | assert.Equal(t, testCase.expectedStatus, response.StatusCode) 107 | responseData, err := io.ReadAll(response.Body) 108 | require.NoError(t, err) 109 | assert.Equal(t, testCase.expectedBody, string(responseData)) 110 | 111 | err = response.Body.Close() 112 | require.NoError(t, err) 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/server/routes/build/interfaces.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | type Logger interface { 4 | Debugf(format string, args ...any) 5 | Error(s string) 6 | } 7 | -------------------------------------------------------------------------------- /internal/server/routes/build/mocks_generate_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | //go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . Logger 4 | -------------------------------------------------------------------------------- /internal/server/routes/build/mocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/qdm12/go-template/internal/server/routes/build (interfaces: Logger) 3 | 4 | // Package build is a generated GoMock package. 5 | package build 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | ) 12 | 13 | // MockLogger is a mock of Logger interface. 14 | type MockLogger struct { 15 | ctrl *gomock.Controller 16 | recorder *MockLoggerMockRecorder 17 | } 18 | 19 | // MockLoggerMockRecorder is the mock recorder for MockLogger. 20 | type MockLoggerMockRecorder struct { 21 | mock *MockLogger 22 | } 23 | 24 | // NewMockLogger creates a new mock instance. 25 | func NewMockLogger(ctrl *gomock.Controller) *MockLogger { 26 | mock := &MockLogger{ctrl: ctrl} 27 | mock.recorder = &MockLoggerMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Debugf mocks base method. 37 | func (m *MockLogger) Debugf(arg0 string, arg1 ...interface{}) { 38 | m.ctrl.T.Helper() 39 | varargs := []interface{}{arg0} 40 | for _, a := range arg1 { 41 | varargs = append(varargs, a) 42 | } 43 | m.ctrl.Call(m, "Debugf", varargs...) 44 | } 45 | 46 | // Debugf indicates an expected call of Debugf. 47 | func (mr *MockLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | varargs := append([]interface{}{arg0}, arg1...) 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) 51 | } 52 | 53 | // Error mocks base method. 54 | func (m *MockLogger) Error(arg0 string) { 55 | m.ctrl.T.Helper() 56 | m.ctrl.Call(m, "Error", arg0) 57 | } 58 | 59 | // Error indicates an expected call of Error. 60 | func (mr *MockLoggerMockRecorder) Error(arg0 interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0) 63 | } 64 | -------------------------------------------------------------------------------- /internal/server/routes/build/options.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/qdm12/go-template/internal/server/middlewares/cors" 7 | ) 8 | 9 | func (h *handler) options(w http.ResponseWriter, r *http.Request) { 10 | cors.AllowCORSMethods(r, w, http.MethodGet) 11 | } 12 | -------------------------------------------------------------------------------- /internal/server/routes/users/createuser.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/qdm12/go-template/internal/models" 9 | "github.com/qdm12/go-template/internal/server/contenttype" 10 | "github.com/qdm12/go-template/internal/server/decodejson" 11 | "github.com/qdm12/go-template/internal/server/httperr" 12 | ) 13 | 14 | // Handler for creating a user (POST /users/). 15 | func (h *handler) createUser(w http.ResponseWriter, r *http.Request) { 16 | _, responseContentType, err := contenttype.APICheck(r.Header) 17 | w.Header().Set("Content-Type", responseContentType) 18 | errResponder := httperr.NewResponder(responseContentType, h.logger) 19 | 20 | if err != nil { 21 | errResponder.Respond(w, http.StatusNotAcceptable, err.Error()) 22 | return 23 | } 24 | 25 | var user models.User 26 | const maxBytes = 1024 // 1KB is enough 27 | ok, respondErr := decodejson.DecodeBody(w, maxBytes, r.Body, &user, responseContentType) 28 | if !ok { 29 | if respondErr != nil { 30 | h.logger.Debugf("responding error: %s", respondErr) 31 | } 32 | return 33 | } 34 | 35 | if err := h.proc.CreateUser(r.Context(), user); err != nil { 36 | if errors.Is(err, context.DeadlineExceeded) { 37 | errResponder.Respond(w, http.StatusRequestTimeout, "") 38 | } else { 39 | h.logger.Error(err.Error()) 40 | errResponder.Respond(w, http.StatusInternalServerError, "") 41 | } 42 | return 43 | } 44 | 45 | w.WriteHeader(http.StatusCreated) 46 | // TODO return ID created. ID to be set by data store, not by 47 | // client what the hell. 48 | } 49 | -------------------------------------------------------------------------------- /internal/server/routes/users/getuser.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/go-chi/chi/v5" 12 | dataerr "github.com/qdm12/go-template/internal/data/errors" 13 | "github.com/qdm12/go-template/internal/server/contenttype" 14 | "github.com/qdm12/go-template/internal/server/httperr" 15 | ) 16 | 17 | // Handler to get a user by ID (GET /users/{id}). 18 | func (h *handler) getUserByID(w http.ResponseWriter, r *http.Request) { 19 | _, responseContentType, err := contenttype.APICheck(r.Header) 20 | w.Header().Set("Content-Type", responseContentType) 21 | errResponder := httperr.NewResponder(responseContentType, h.logger) 22 | 23 | if err != nil { 24 | errResponder.Respond(w, http.StatusNotAcceptable, err.Error()) 25 | return 26 | } 27 | 28 | id, err := extractUserID(r) 29 | if err != nil { 30 | errResponder.Respond(w, http.StatusBadRequest, err.Error()) 31 | return 32 | } 33 | 34 | user, err := h.proc.GetUserByID(r.Context(), id) 35 | if err != nil { 36 | switch { 37 | case errors.Is(err, dataerr.ErrUserNotFound): 38 | errResponder.Respond(w, http.StatusNotFound, err.Error()) 39 | case errors.Is(err, context.DeadlineExceeded): 40 | errResponder.Respond(w, http.StatusRequestTimeout, "") 41 | default: 42 | h.logger.Error(err.Error()) 43 | errResponder.Respond(w, http.StatusInternalServerError, "") 44 | } 45 | return 46 | } 47 | 48 | err = json.NewEncoder(w).Encode(user) 49 | if err != nil { 50 | h.logger.Error(err.Error()) 51 | errResponder.Respond(w, http.StatusInternalServerError, "") 52 | return 53 | } 54 | } 55 | 56 | var ( 57 | errUserIDMissingURLPath = errors.New("user ID must be provided in the URL path") 58 | errUserIDMalformed = errors.New("user ID is malformed") 59 | ) 60 | 61 | func extractUserID(r *http.Request) (id uint64, err error) { 62 | s := chi.URLParam(r, "id") 63 | if s == "" { 64 | return 0, errUserIDMissingURLPath 65 | } 66 | id, err = strconv.ParseUint(s, 10, 64) 67 | if err != nil { 68 | return 0, fmt.Errorf("%w: %q", errUserIDMalformed, s) 69 | } 70 | return id, nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/server/routes/users/handler.go: -------------------------------------------------------------------------------- 1 | // Package users is the HTTP handler for the users. 2 | package users 3 | 4 | import ( 5 | "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | type handler struct { 9 | proc Processor 10 | logger Logger 11 | } 12 | 13 | func NewHandler(logger Logger, proc Processor) *chi.Mux { 14 | h := &handler{ 15 | proc: proc, 16 | logger: logger, 17 | } 18 | router := chi.NewRouter() 19 | router.Get("/{id}", h.getUserByID) 20 | router.Post("/", h.createUser) 21 | router.Options("/", h.options) 22 | return router 23 | } 24 | -------------------------------------------------------------------------------- /internal/server/routes/users/interfaces.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/qdm12/go-template/internal/models" 7 | ) 8 | 9 | type Logger interface { 10 | Debugf(format string, args ...any) 11 | Error(s string) 12 | } 13 | 14 | type Processor interface { 15 | CreateUser(ctx context.Context, user models.User) error 16 | GetUserByID(ctx context.Context, id uint64) (user models.User, err error) 17 | } 18 | -------------------------------------------------------------------------------- /internal/server/routes/users/options.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/qdm12/go-template/internal/server/middlewares/cors" 7 | ) 8 | 9 | func (h *handler) options(w http.ResponseWriter, r *http.Request) { 10 | cors.AllowCORSMethods(r, w, http.MethodPost, http.MethodGet) 11 | } 12 | -------------------------------------------------------------------------------- /internal/server/websocket/handler.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "golang.org/x/net/websocket" 5 | ) 6 | 7 | func New() *websocket.Server { 8 | return &websocket.Server{ 9 | Config: websocket.Config{ 10 | Origin: nil, 11 | }, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14-alpine 2 | COPY schema.sql /docker-entrypoint-initdb.d 3 | -------------------------------------------------------------------------------- /postgres/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id INTEGER PRIMARY KEY, 3 | account TEXT NOT NULL, 4 | username TEXT NOT NULL, 5 | email TEXT NOT NULL, 6 | ); -------------------------------------------------------------------------------- /title.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 119 | 124 | 125 | 130 | 135 | 140 | 143 | 150 | 157 | 158 | 161 | 168 | 175 | 176 | 181 | 184 | 189 | 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Main 4 | 5 | ## End to end 6 | 7 | - [ ] Add authentication using credentials + (persisted) token 8 | - [ ] Add support for temporary auth codes sent by email 9 | 10 | ## Config 11 | 12 | - [ ] Read secrets from files in `/var/run/` 13 | 14 | ## Server 15 | 16 | - [ ] More unit tests for the server 17 | - [ ] Generate ID in database when creating a user 18 | 19 | ## Database 20 | 21 | - [ ] Add bbolt as database type 22 | 23 | ### Postgres 24 | 25 | - [ ] Add table creation in Go 26 | - [ ] Setup parallel testing for integration tests 27 | - [ ] Add seeding 28 | 29 | ## CI 30 | 31 | - [ ] Goreleaser 32 | --------------------------------------------------------------------------------