From 51c26ca11710815e61fe37cfc613161d75898888 Mon Sep 17 00:00:00 2001 From: Jumpy Squirrel Date: Mon, 1 Jul 2024 20:30:28 +0200 Subject: [PATCH] clean generate from updated template --- .github/dependabot.yml | 3 - .github/workflows/codeql-analysis.yml | 71 -- .github/workflows/docker.yml | 67 ++ .github/workflows/go.yml | 14 +- .gitignore | 5 +- .golangci.yaml | 219 ----- Dockerfile | 18 + LICENSE | 2 +- README.md | 42 +- api/generator/generate.sh | 38 + api/generator/postprocess.go | 55 ++ api/openapi-spec.yaml | 184 ++++ api/openapi-spec/openapi.yaml | 801 ------------------ cmd/main.go | 33 +- docs/config.example.yaml | 4 - docs/local-config.template.yaml | 7 + docs/testdocs.go | 19 +- generated-main.yaml | 2 +- go.mod | 34 +- go.sum | 73 +- internal/apimodel/apimodel.go | 28 + internal/application/app/app.go | 126 +++ internal/application/common/context.go | 105 +++ internal/application/common/errors.go | 135 +++ internal/application/middleware/cors.go | 74 ++ internal/application/middleware/recoverer.go | 27 + internal/application/middleware/reqid.go | 42 + .../application/middleware/requestlogging.go | 75 ++ .../application/middleware/requestmetrics.go | 70 ++ internal/application/middleware/security.go | 298 +++++++ .../application/middleware/security_test.go | 193 +++++ internal/application/middleware/timeout.go | 20 + internal/application/server/lowlevel.go | 136 +++ internal/application/server/server.go | 144 ++++ internal/application/web/endpoint.go | 60 ++ internal/application/web/endpoint_test.go | 137 +++ internal/application/web/response.go | 84 ++ internal/config/config.go | 40 - internal/controller/examplectl/examplectl.go | 50 ++ .../controller/examplectl/examplectl_get.go | 59 ++ .../controller/examplectl/examplectl_post.go | 54 ++ internal/controller/infoctl/infoctl.go | 29 + internal/controller/infoctl/infoctl_get.go | 22 + .../logging/consolelogging/implementation.go | 48 -- .../consolelogging/implementation_test.go | 23 - .../consolelogging/logformat/logformat.go | 11 - internal/logging/logging.go | 49 -- .../repository/configuration/configuration.go | 49 ++ internal/repository/database/database.go | 7 - internal/repository/entities/foo.go | 9 - internal/repository/idp/client.go | 224 +++++ internal/repository/idp/interface.go | 101 +++ internal/repository/logging/logging.go | 135 +++ internal/repository/timestamp/timestamp.go | 17 + internal/repository/vault/vault.go | 327 +++++++ internal/repository/vault/vault_test.go | 157 ++++ internal/restapi/media/constants.go | 4 - internal/restapi/middleware/corsfilter.go | 43 - internal/restapi/middleware/logreqid.go | 28 - internal/restapi/middleware/reqid.go | 56 -- internal/restapi/v1/health/health.go | 36 - internal/restapi/v1/health/health_models.go | 7 - internal/server/server.go | 65 -- internal/service/example/example.go | 43 + test/acceptance/acc_example_test.go | 42 + test/acceptance/acc_health_test.go | 25 + test/acceptance/dummy.go | 3 + test/acceptance/local-config.yaml | 4 + test/acceptance/main_test.go | 15 + test/acceptance/setup_test.go | 59 ++ test/acceptance/tokens_test.go | 50 ++ test/acceptance/utils_test.go | 165 ++++ test/mocks/idpmock/idpmock.go | 61 ++ 73 files changed, 3969 insertions(+), 1593 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/docker.yml delete mode 100644 .golangci.yaml create mode 100644 Dockerfile create mode 100644 api/generator/generate.sh create mode 100644 api/generator/postprocess.go create mode 100644 api/openapi-spec.yaml delete mode 100644 api/openapi-spec/openapi.yaml delete mode 100644 docs/config.example.yaml create mode 100644 docs/local-config.template.yaml create mode 100644 internal/apimodel/apimodel.go create mode 100644 internal/application/app/app.go create mode 100644 internal/application/common/context.go create mode 100644 internal/application/common/errors.go create mode 100644 internal/application/middleware/cors.go create mode 100644 internal/application/middleware/recoverer.go create mode 100644 internal/application/middleware/reqid.go create mode 100644 internal/application/middleware/requestlogging.go create mode 100644 internal/application/middleware/requestmetrics.go create mode 100644 internal/application/middleware/security.go create mode 100644 internal/application/middleware/security_test.go create mode 100644 internal/application/middleware/timeout.go create mode 100644 internal/application/server/lowlevel.go create mode 100644 internal/application/server/server.go create mode 100644 internal/application/web/endpoint.go create mode 100644 internal/application/web/endpoint_test.go create mode 100644 internal/application/web/response.go delete mode 100644 internal/config/config.go create mode 100644 internal/controller/examplectl/examplectl.go create mode 100644 internal/controller/examplectl/examplectl_get.go create mode 100644 internal/controller/examplectl/examplectl_post.go create mode 100644 internal/controller/infoctl/infoctl.go create mode 100644 internal/controller/infoctl/infoctl_get.go delete mode 100644 internal/logging/consolelogging/implementation.go delete mode 100644 internal/logging/consolelogging/implementation_test.go delete mode 100644 internal/logging/consolelogging/logformat/logformat.go delete mode 100644 internal/logging/logging.go create mode 100644 internal/repository/configuration/configuration.go delete mode 100644 internal/repository/database/database.go delete mode 100644 internal/repository/entities/foo.go create mode 100644 internal/repository/idp/client.go create mode 100644 internal/repository/idp/interface.go create mode 100644 internal/repository/logging/logging.go create mode 100644 internal/repository/timestamp/timestamp.go create mode 100644 internal/repository/vault/vault.go create mode 100644 internal/repository/vault/vault_test.go delete mode 100644 internal/restapi/media/constants.go delete mode 100644 internal/restapi/middleware/corsfilter.go delete mode 100644 internal/restapi/middleware/logreqid.go delete mode 100644 internal/restapi/middleware/reqid.go delete mode 100644 internal/restapi/v1/health/health.go delete mode 100644 internal/restapi/v1/health/health_models.go delete mode 100644 internal/server/server.go create mode 100644 internal/service/example/example.go create mode 100644 test/acceptance/acc_example_test.go create mode 100644 test/acceptance/acc_health_test.go create mode 100644 test/acceptance/dummy.go create mode 100644 test/acceptance/local-config.yaml create mode 100644 test/acceptance/main_test.go create mode 100644 test/acceptance/setup_test.go create mode 100644 test/acceptance/tokens_test.go create mode 100644 test/acceptance/utils_test.go create mode 100644 test/mocks/idpmock/idpmock.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 714a63b..92b95b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,3 @@ - # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: @@ -10,7 +9,5 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" - labels: - - "dependencies" reviewers: - "Jumpy-Squirrel" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index c2cc666..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '24 19 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'go' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..60bec16 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,67 @@ +name: Create and publish Docker image + +on: + push: + branches: + - 'main' + +jobs: + build-and-push-docker-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Print debugging information + run: > + echo registry="$REGISTRY" && + echo image-name="$IMAGE_NAME" && + echo image-tags="$IMAGE_TAGS" && + echo full-repo-url="$FULL_REPO_URL" && + echo branch-or-tag-name="$BRANCH_OR_TAG_NAME" && + echo commit-hash="$COMMIT_HASH" && + echo registry-user="$REGISTRY_USER" + shell: bash + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + IMAGE_TAGS: latest + FULL_REPO_URL: https://github.com/${{ github.repository }} + BRANCH_OR_TAG_NAME: ${{ github.ref_name }} + COMMIT_HASH: ${{ github.sha }} + REGISTRY_USER: ${{ github.actor }} + + - name: Checkout repository + run: 'git clone -b "$BRANCH_OR_TAG_NAME" --depth 1 "$REPO_URL_WITH_AUTH" app' + shell: bash + env: + REPO_URL_WITH_AUTH: https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + BRANCH_OR_TAG_NAME: ${{ github.ref_name }} + + - name: Log in to the Container registry + run: 'echo "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin' + shell: bash + env: + REGISTRY: ghcr.io + REGISTRY_USER: ${{ github.actor }} + REGISTRY_PASS: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker build and push image + run: > + cd app && + TAG_ARGS=$(echo -n "$IMAGE_TAGS" | sed -r "s_([^ :/]+)_ --tag $REGISTRY/${IMAGE_NAME,,}:\1 _g") && + docker build + --label org.opencontainers.image.url="$FULL_REPO_URL" + --label org.opencontainers.image.revision="$COMMIT_HASH" + $TAG_ARGS + --pull + -f Dockerfile + . && + docker push -a "$REGISTRY/${IMAGE_NAME,,}" + shell: bash + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + IMAGE_TAGS: latest + FULL_REPO_URL: https://github.com/${{ github.repository }} + COMMIT_HASH: ${{ github.sha }} \ No newline at end of file diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 581f07d..4697977 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,15 +15,23 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + path: . - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: 1.16 + go-version: '^1.22.2' - name: Build run: go build -v ./... + working-directory: . - name: Test run: go test -v ./... + working-directory: . + + - name: Vet + run: go vet ./... + working-directory: . diff --git a/.gitignore b/.gitignore index 41e8b4d..e72e6bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/** +.vscode/** +api/generator/openapi-generator-cli-*.jar *.exe +/local-config.yaml main reg-backend-template-test -**/*.swp -config.yaml diff --git a/.golangci.yaml b/.golangci.yaml deleted file mode 100644 index 66bc5bf..0000000 --- a/.golangci.yaml +++ /dev/null @@ -1,219 +0,0 @@ -# -# Config file for `golangci-lint` tool -# - -# options for analysis running -run: - # exit code when at least one issue was found, default is 1 - issues-exit-code: 1 - - # include test files or not, default is true - tests: true - - # list of build tags, all linters use it. Default is empty list. - # build-tags: - # - mytag - - # default is true. Enables skipping of directories: - # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - skip-dirs-use-default: true - - - # Allow multiple parallel golangci-lint instances running. - # If false (default) - golangci-lint acquires file lock on start. - allow-parallel-runners: false - - -# output configuration options -output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions - # default is "colored-line-number" - format: colored-line-number - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true - - # make issues output unique by line, default is true - uniq-by-line: true - - # add a prefix to the output file references; default is no prefix - path-prefix: "" - - # sorts results by: filepath, line and column - sort-results: false - - -# all available settings of specific linters -linters-settings: - gofmt: - # simplify code: gofmt with `-s` option, true by default - simplify: true - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - local-prefixes: github.com/eurofurence/reg-backend-template-test - revive: - # minimal confidence for issues, default is 0.8 - min-confidence: 0.8 - govet: - # report about shadowed variables - check-shadowing: true - - # settings per analyzer - settings: - printf: # analyzer name, run `go tool vet help` to see all analyzers - funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf - - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf - - # enable or disable analyzers by name - # run `go tool vet help` to see all analyzers - enable: - - asmdecl - - assign - - atomic - - bools - - buildtag - - cgocall - - composites - - copylocks - - errorsas - - framepointer - - httpresponse - - ifaceassert - - loopclosure - - lostcancel - - nilfunc - - printf - - shift - - stdmethods - - stringintconv - - structtag - - testinggoroutine - - tests - - unmarshal - - unreachable - - unsafeptr - - unusedresult - enable-all: false - disable: - - shadow - disable-all: false - -linters: - enable: - - gofmt - - goimports - - revive - - govet - - gosec - disable: - - bodyclose - - contextcheck - - nilerr - - noctx - - rowserrcheck - - sqlclosecheck - - staticcheck - - structcheck - - unparam - - unused - - scopelint - disable-all: false - presets: - - bugs - - unused - fast: false - - -issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently from this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` - # exclude: - # - abcdef - -# # Excluding configuration per-path, per-linter, per-text and per-source -# exclude-rules: -# # Exclude some linters from running on tests files. -# - path: _test\.go -# linters: -# - gocyclo -# - errcheck -# - dupl -# - gosec -# -# # Exclude known linters from partially hard-vendored code, -# # which is impossible to exclude via "nolint" comments. -# - path: internal/hmac/ -# text: "weak cryptographic primitive" -# linters: -# - gosec -# -# # Exclude some staticcheck messages -# - linters: -# - staticcheck -# text: "SA9003:" -# -# # Exclude lll issues for long lines with go:generate -# - linters: -# - lll -# source: "^//go:generate " - - # Independently from option `exclude` we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute `golangci-lint run --help`. - # Default value for this option is true. - exclude-use-default: false - - # The default value is false. If set to true exclude and exclude-rules - # regular expressions become case sensitive. - exclude-case-sensitive: false - - # The list of ids of default excludes to include or disable. By default it's empty. -# include: -# - EXC0002 # disable excluding of issues about comments from golint - - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. - max-issues-per-linter: 0 - - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. - max-same-issues: 0 - - # Show only new issues: if there are unstaged changes or untracked files, - # only those changes are analyzed, else only changes in HEAD~ are analyzed. - # It's a super-useful option for integration of golangci-lint into existing - # large codebase. It's not practical to fix all existing issues at the moment - # of integration: much better don't allow issues in new code. - # Default is false. - new: false - - # Show only new issues created after git revision `REV` - # new-from-rev: REV - - # Show only new issues created in git patch with set file path. - # new-from-patch: path/to/patch/file - - # Fix found issues (if it's supported by the linter) - fix: true - -severity: - # Default value is empty string. - # Set the default severity for issues. If severity rules are defined and the issues - # do not match or no severity is provided to the rule this will be the default - # severity applied. Severities should match the supported severity names of the - # selected out format. - # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity - # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity - # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message - default-severity: error - - # The default value is false. - # If set to true severity-rules regular expressions become case sensitive. - case-sensitive: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd732ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1 as build + +COPY . /app +WORKDIR /app + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" cmd/main.go + +RUN chmod 755 main + +FROM scratch + +COPY --from=build /app/main /main +COPY --from=build /etc/ssl/certs /etc/ssl/certs + +# run as an unprivileged unnamed user that has no write permissions anywhere binary +USER 8877 + +ENTRYPOINT ["/main"] \ No newline at end of file diff --git a/LICENSE b/LICENSE index dafc491..0a158b4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Eurofurence e.V. +Copyright (c) 2024 Eurofurence e.V. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index cbc076e..3832458 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,44 @@ # reg-backend-template-test test status -code quality status ## Overview -A backend service... - -Implemented in go. - -Command line arguments -```-config [-migrate-database]``` +Purpose of the service. ## Installation This service uses go modules to provide dependency management, see `go.mod`. -To install required dependencies run `go mod download` +If you place this repository outside your gopath, `go build cmd/main.go` and +`go test ./...` will download all required dependencies by default. + +## Generate models + +In a shell or git bash in the project root, run `./api/generator/generate.sh`. + +Models are checked in for convenience and change tracking. + +_Note: the generator needs a current Java runtime environment._ + +## Running on development system + +Copy the configuration template from `docs/local-config.template.yaml` to `./local-config.yaml` +and edit as needed. + +Then run `go run cmd/main.go`. + +## Test Coverage + +In order to collect full test coverage, set go tool arguments to `-coverpkg=./internal/...`, +or manually run +``` +go test -coverpkg=./internal/... ./... +``` + +## Architecture -If you place this repository OUTSIDE of your gopath, `go build cmd/main.go` and `go test ./...` will download all -required dependencies by default. +Components are grouped by stereotypes: + * controller = anything that's being interacted with from the outside + * service = business logic + * repository = anything that interacts with the outside diff --git a/api/generator/generate.sh b/api/generator/generate.sh new file mode 100644 index 0000000..7385cc1 --- /dev/null +++ b/api/generator/generate.sh @@ -0,0 +1,38 @@ +#! /bin/bash + +set -e + +if [ -d "api" ]; then + cd api +fi + +if [ -d "generator" ]; then + cd generator +fi + +GENERATOR_VERSION="7.6.0" +GENERATOR="openapi-generator-cli-$GENERATOR_VERSION.jar" + +if [ ! -f "$GENERATOR" ]; then + curl https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/$GENERATOR_VERSION/$GENERATOR > $GENERATOR +fi + +API_MODEL_PACKAGE_NAME="apimodel" + +rm -rf tmp + +mkdir -p tmp + +java -jar "$GENERATOR" generate \ + -i "../openapi-spec.yaml" \ + -o "tmp/$API_MODEL_PACKAGE_NAME" \ + --package-name "$API_MODEL_PACKAGE_NAME" \ + --global-property models,modelTests=false,modelDocs=false \ + -g go + +( cat tmp/"$API_MODEL_PACKAGE_NAME"/model_*.go | \ + go run postprocess.go "$API_MODEL_PACKAGE_NAME" > "../../internal/$API_MODEL_PACKAGE_NAME/apimodel.go" || \ + (rm -rf tmp && exit 1) \ +); rm -rf tmp + +gofmt -l -w "../../internal/$API_MODEL_PACKAGE_NAME/apimodel.go" diff --git a/api/generator/postprocess.go b/api/generator/postprocess.go new file mode 100644 index 0000000..1e02b13 --- /dev/null +++ b/api/generator/postprocess.go @@ -0,0 +1,55 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "regexp" + "strings" +) + +var regexTypeBegins = regexp.MustCompile(`^type .* {$`) + +func main() { + fmt.Printf(`// Code generated by generate.sh using openapi-generator-cli. DO NOT EDIT. + +package %s + +import "time" + +var _ = time.Now + +`, packageNameArg()) + + enabled := false + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "type Nullable") { + // ignore + } else if regexTypeBegins.MatchString(line) { + fmt.Println(line) + enabled = true + } else if line == "}" && enabled { + fmt.Println(line) + fmt.Println() + enabled = false + } else if enabled { + fmt.Println(line) + } + } + + if err := scanner.Err(); err != nil { + log.Println(err) + os.Exit(2) + } +} + +func packageNameArg() string { + if len(os.Args) != 2 { + log.Println("required first argument missing: package name") + os.Exit(1) + } + return os.Args[1] +} diff --git a/api/openapi-spec.yaml b/api/openapi-spec.yaml new file mode 100644 index 0000000..bd0aa8b --- /dev/null +++ b/api/openapi-spec.yaml @@ -0,0 +1,184 @@ +openapi: 3.1.0 +info: + title: reg-backend-template-test + description: |- + This backend microservice is a template. + license: + name: MIT + url: https://github.com/eurofurence/reg-backend-template-test/blob/main/LICENSE + version: 0.1.0 +tags: + - name: info + description: health and other public status information + - name: example + description: example stuff +paths: + /: + get: + tags: + - info + summary: health + description: The health check for this service. + operationId: health + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /api/rest/v1/example: + get: + tags: + - example + summary: example + description: Get the next example value. + operationId: GetExample + parameters: + - name: min_value + in: query + description: only get example values that are above the threshold, if specified + required: false + schema: + type: number + example: 14 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Example' + '400': + description: Invalid parameter. min_value must be a valid integer. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authorization required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Value outside acceptable range + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - BearerAuth: [] + - ApiKeyAuth: [] + /api/rest/v1/example/{category}: + post: + tags: + - example + summary: example + description: Set the example value. This is of course a silly example. + operationId: SetExample + parameters: + - name: category + in: path + description: a category to set the value for + required: true + schema: + type: string + example: squirrels + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Example' + responses: + '204': + description: successful operation + '400': + description: Invalid request body or path parameter. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authorization required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - BearerAuth: [] + - ApiKeyAuth: [] +components: + schemas: + Error: + type: object + required: + - message + - timestamp + - requestid + properties: + timestamp: + type: string + format: date-time + description: The time at which the error occurred. + example: 2006-01-02T15:04:05+07:00 + requestid: + type: string + description: An internal trace id assigned to the error. Used to find logs associated with errors across our services. Display to the user as something to communicate to us with inquiries about the error. + example: a8b7c6d5 + message: + type: string + description: |- + A keyed description of the error. Intentionally made machine readable to provide fairly fine grained + error classification. Also useful to get meaningful errors in internationalized UI client. + + At this time, there are these values: + - auth.unauthorized (token missing completely or invalid) + - auth.forbidden (permissions missing) + - request.parse.failed + - value.too.high (an example of a business logic exception) + - value.too.low (another example of a business logic exception) + - error.internal + - error.unknown + example: auth.unauthorized + details: + type: object + additionalProperties: + type: array + items: + type: string + description: Optional additional details about the error. If available, will usually contain English language technobabble. + example: + name: + - the name cannot be longer than 80 characters + other: + - you need to refill the flux capacitor before the operation can succeed + Example: + type: object + required: + - value + properties: + value: + type: integer + format: int64 + description: A random example value that is generated by the business logic. + example: 12648 + Health: + type: object + required: + - status + properties: + status: + type: string + description: the status of this service. If you get a response at all, status will be "OK". + example: OK + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: A bearer or session token obtained from your OpenID Connect Identity Provider. + ApiKeyAuth: + type: apiKey + in: header + name: X-Api-Key + description: A shared secret used for local communication (also useful for local development) diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml deleted file mode 100644 index 12d901c..0000000 --- a/api/openapi-spec/openapi.yaml +++ /dev/null @@ -1,801 +0,0 @@ -openapi: 3.0.3 -info: - title: Swagger Petstore - OpenAPI 3.0 - description: |- - This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about - Swagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! - You can now help us improve the API whether it's by making changes to the definition itself or to the code. - That way, with time, we can improve the API in general, and expose some of the new features in OAS3. - - Some useful links: - - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) - - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) - - termsOfService: http://swagger.io/terms/ - contact: - email: apiteam@swagger.io - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - version: 1.0.11 -externalDocs: - description: Find out more about Swagger - url: http://swagger.io -servers: - - url: https://petstore3.swagger.io/api/v3 -tags: - - name: pet - description: Everything about your Pets - externalDocs: - description: Find out more - url: http://swagger.io - - name: store - description: Access to Petstore orders - externalDocs: - description: Find out more about our store - url: http://swagger.io - - name: user - description: Operations about user -paths: - /pet: - put: - tags: - - pet - summary: Update an existing pet - description: Update an existing pet by Id - operationId: updatePet - requestBody: - description: Update an existent pet in the store - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Pet' - required: true - responses: - '200': - description: Successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - '400': - description: Invalid ID supplied - '404': - description: Pet not found - '405': - description: Validation exception - security: - - petstore_auth: - - write:pets - - read:pets - post: - tags: - - pet - summary: Add a new pet to the store - description: Add a new pet to the store - operationId: addPet - requestBody: - description: Create a new pet in the store - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Pet' - required: true - responses: - '200': - description: Successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - '405': - description: Invalid input - security: - - petstore_auth: - - write:pets - - read:pets - /pet/findByStatus: - get: - tags: - - pet - summary: Finds Pets by status - description: Multiple status values can be provided with comma separated strings - operationId: findPetsByStatus - parameters: - - name: status - in: query - description: Status values that need to be considered for filter - required: false - explode: true - schema: - type: string - default: available - enum: - - available - - pending - - sold - responses: - '200': - description: successful operation - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - type: array - items: - $ref: '#/components/schemas/Pet' - '400': - description: Invalid status value - security: - - petstore_auth: - - write:pets - - read:pets - /pet/findByTags: - get: - tags: - - pet - summary: Finds Pets by tags - description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. - operationId: findPetsByTags - parameters: - - name: tags - in: query - description: Tags to filter by - required: false - explode: true - schema: - type: array - items: - type: string - responses: - '200': - description: successful operation - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - type: array - items: - $ref: '#/components/schemas/Pet' - '400': - description: Invalid tag value - security: - - petstore_auth: - - write:pets - - read:pets - /pet/{petId}: - get: - tags: - - pet - summary: Find pet by ID - description: Returns a single pet - operationId: getPetById - parameters: - - name: petId - in: path - description: ID of pet to return - required: true - schema: - type: integer - format: int64 - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - '400': - description: Invalid ID supplied - '404': - description: Pet not found - security: - - api_key: [] - - petstore_auth: - - write:pets - - read:pets - post: - tags: - - pet - summary: Updates a pet in the store with form data - description: '' - operationId: updatePetWithForm - parameters: - - name: petId - in: path - description: ID of pet that needs to be updated - required: true - schema: - type: integer - format: int64 - - name: name - in: query - description: Name of pet that needs to be updated - schema: - type: string - - name: status - in: query - description: Status of pet that needs to be updated - schema: - type: string - responses: - '405': - description: Invalid input - security: - - petstore_auth: - - write:pets - - read:pets - delete: - tags: - - pet - summary: Deletes a pet - description: delete a pet - operationId: deletePet - parameters: - - name: api_key - in: header - description: '' - required: false - schema: - type: string - - name: petId - in: path - description: Pet id to delete - required: true - schema: - type: integer - format: int64 - responses: - '400': - description: Invalid pet value - security: - - petstore_auth: - - write:pets - - read:pets - /pet/{petId}/uploadImage: - post: - tags: - - pet - summary: uploads an image - description: '' - operationId: uploadFile - parameters: - - name: petId - in: path - description: ID of pet to update - required: true - schema: - type: integer - format: int64 - - name: additionalMetadata - in: query - description: Additional Metadata - required: false - schema: - type: string - requestBody: - content: - application/octet-stream: - schema: - type: string - format: binary - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - security: - - petstore_auth: - - write:pets - - read:pets - /store/inventory: - get: - tags: - - store - summary: Returns pet inventories by status - description: Returns a map of status codes to quantities - operationId: getInventory - responses: - '200': - description: successful operation - content: - application/json: - schema: - type: object - additionalProperties: - type: integer - format: int32 - security: - - api_key: [] - /store/order: - post: - tags: - - store - summary: Place an order for a pet - description: Place a new order in the store - operationId: placeOrder - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/Order' - application/xml: - schema: - $ref: '#/components/schemas/Order' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Order' - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/Order' - '405': - description: Invalid input - /store/order/{orderId}: - get: - tags: - - store - summary: Find purchase order by ID - description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. - operationId: getOrderById - parameters: - - name: orderId - in: path - description: ID of order that needs to be fetched - required: true - schema: - type: integer - format: int64 - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/Order' - application/xml: - schema: - $ref: '#/components/schemas/Order' - '400': - description: Invalid ID supplied - '404': - description: Order not found - delete: - tags: - - store - summary: Delete purchase order by ID - description: For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors - operationId: deleteOrder - parameters: - - name: orderId - in: path - description: ID of the order that needs to be deleted - required: true - schema: - type: integer - format: int64 - responses: - '400': - description: Invalid ID supplied - '404': - description: Order not found - /user: - post: - tags: - - user - summary: Create user - description: This can only be done by the logged in user. - operationId: createUser - requestBody: - description: Created user object - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/User' - responses: - default: - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - /user/createWithList: - post: - tags: - - user - summary: Creates list of users with given input array - description: Creates list of users with given input array - operationId: createUsersWithListInput - requestBody: - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' - responses: - '200': - description: Successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - default: - description: successful operation - /user/login: - get: - tags: - - user - summary: Logs user into the system - description: '' - operationId: loginUser - parameters: - - name: username - in: query - description: The user name for login - required: false - schema: - type: string - - name: password - in: query - description: The password for login in clear text - required: false - schema: - type: string - responses: - '200': - description: successful operation - headers: - X-Rate-Limit: - description: calls per hour allowed by the user - schema: - type: integer - format: int32 - X-Expires-After: - description: date in UTC when token expires - schema: - type: string - format: date-time - content: - application/xml: - schema: - type: string - application/json: - schema: - type: string - '400': - description: Invalid username/password supplied - /user/logout: - get: - tags: - - user - summary: Logs out current logged in user session - description: '' - operationId: logoutUser - parameters: [] - responses: - default: - description: successful operation - /user/{username}: - get: - tags: - - user - summary: Get user by user name - description: '' - operationId: getUserByName - parameters: - - name: username - in: path - description: 'The name that needs to be fetched. Use user1 for testing. ' - required: true - schema: - type: string - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - '400': - description: Invalid username supplied - '404': - description: User not found - put: - tags: - - user - summary: Update user - description: This can only be done by the logged in user. - operationId: updateUser - parameters: - - name: username - in: path - description: name that need to be deleted - required: true - schema: - type: string - requestBody: - description: Update an existent user in the store - content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/User' - responses: - default: - description: successful operation - delete: - tags: - - user - summary: Delete user - description: This can only be done by the logged in user. - operationId: deleteUser - parameters: - - name: username - in: path - description: The name that needs to be deleted - required: true - schema: - type: string - responses: - '400': - description: Invalid username supplied - '404': - description: User not found -components: - schemas: - Order: - type: object - properties: - id: - type: integer - format: int64 - example: 10 - petId: - type: integer - format: int64 - example: 198772 - quantity: - type: integer - format: int32 - example: 7 - shipDate: - type: string - format: date-time - status: - type: string - description: Order Status - example: approved - enum: - - placed - - approved - - delivered - complete: - type: boolean - xml: - name: order - Customer: - type: object - properties: - id: - type: integer - format: int64 - example: 100000 - username: - type: string - example: fehguy - address: - type: array - xml: - name: addresses - wrapped: true - items: - $ref: '#/components/schemas/Address' - xml: - name: customer - Address: - type: object - properties: - street: - type: string - example: 437 Lytton - city: - type: string - example: Palo Alto - state: - type: string - example: CA - zip: - type: string - example: '94301' - xml: - name: address - Category: - type: object - properties: - id: - type: integer - format: int64 - example: 1 - name: - type: string - example: Dogs - xml: - name: category - User: - type: object - properties: - id: - type: integer - format: int64 - example: 10 - username: - type: string - example: theUser - firstName: - type: string - example: John - lastName: - type: string - example: James - email: - type: string - example: john@email.com - password: - type: string - example: '12345' - phone: - type: string - example: '12345' - userStatus: - type: integer - description: User Status - format: int32 - example: 1 - xml: - name: user - Tag: - type: object - properties: - id: - type: integer - format: int64 - name: - type: string - xml: - name: tag - Pet: - required: - - name - - photoUrls - type: object - properties: - id: - type: integer - format: int64 - example: 10 - name: - type: string - example: doggie - category: - $ref: '#/components/schemas/Category' - photoUrls: - type: array - xml: - wrapped: true - items: - type: string - xml: - name: photoUrl - tags: - type: array - xml: - wrapped: true - items: - $ref: '#/components/schemas/Tag' - status: - type: string - description: pet status in the store - enum: - - available - - pending - - sold - xml: - name: pet - ApiResponse: - type: object - properties: - code: - type: integer - format: int32 - type: - type: string - message: - type: string - xml: - name: '##default' - requestBodies: - Pet: - description: Pet object that needs to be added to the store - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - UserArray: - description: List of user object - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' - securitySchemes: - petstore_auth: - type: oauth2 - flows: - implicit: - authorizationUrl: https://petstore3.swagger.io/oauth/authorize - scopes: - write:pets: modify pets in your account - read:pets: read your pets - api_key: - type: apiKey - name: api_key - in: header diff --git a/cmd/main.go b/cmd/main.go index 8dbee65..3fc28cd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,39 +1,10 @@ package main import ( - "github.com/eurofurence/reg-backend-template-test/internal/logging" - "github.com/eurofurence/reg-backend-template-test/internal/server" - - "context" + "github.com/eurofurence/reg-backend-template-test/internal/application/app" "os" - "os/signal" - "syscall" - "time" ) func main() { - // TODO start implementing your service here - - logging.NoCtx().Info("Service is starting") - - ctx, cancel := context.WithCancel(context.Background()) - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - - go func() { - <-sig - cancel() - logging.NoCtx().Info("Stopping service now") - - tCtx, tcancel := context.WithTimeout(context.Background(), time.Second*5) - defer tcancel() - - if err := server.Shutdown(tCtx); err != nil { - logging.NoCtx().Fatal("Couldn't shutdown server gracefully") - } - }() - - handler := server.Create() - server.Serve(ctx, handler) + os.Exit(app.New().Run()) } diff --git a/docs/config.example.yaml b/docs/config.example.yaml deleted file mode 100644 index 263e1d2..0000000 --- a/docs/config.example.yaml +++ /dev/null @@ -1,4 +0,0 @@ -server: - port: 8081 -security: - disable_cors: false diff --git a/docs/local-config.template.yaml b/docs/local-config.template.yaml new file mode 100644 index 0000000..6b045f0 --- /dev/null +++ b/docs/local-config.template.yaml @@ -0,0 +1,7 @@ +# copy this file to ../local-config.yaml to avoid having to set environment variables +# when running locally. + +# SERVER_ADDRESS: "0.0.0.0" +SERVER_PORT: "8080" +LOG_LEVEL: "INFO" +LOG_STYLE: "plain" diff --git a/docs/testdocs.go b/docs/testdocs.go index d11b967..fc84048 100644 --- a/docs/testdocs.go +++ b/docs/testdocs.go @@ -1,27 +1,30 @@ -// this file is automatically generated and not intended to be edited - package docs -import "log" +import ( + "fmt" +) // these do nothing really, but they make tests and their log output way more readable +const indentation = " " +const iLimitation = "LIMITATION: " + func Given(s string) { - log.Print(s) + fmt.Println(indentation, s) } func When(s string) { - log.Print(s) + fmt.Println(indentation, s) } func Then(s string) { - log.Print(s) + fmt.Println(indentation, s) } func Description(s string) { - log.Print(s) + fmt.Println(indentation, s) } func Limitation(s string) { - log.Print("LIMITATION: " + s) + fmt.Println(iLimitation + s) } diff --git a/generated-main.yaml b/generated-main.yaml index 1c64f99..a113676 100644 --- a/generated-main.yaml +++ b/generated-main.yaml @@ -1,7 +1,7 @@ generator: main parameters: licenseOwner: Eurofurence e.V. - licenseYear: "2021" + licenseYear: "2024" maintainerEmail: jsquirrel_github_9a6d@packetloss.de repoBaseUrl: github.com/eurofurence reviewers: diff --git a/go.mod b/go.mod index 5a7eda9..1576c7e 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,38 @@ module github.com/eurofurence/reg-backend-template-test -go 1.18 +go 1.22.2 require ( - github.com/go-chi/chi/v5 v5.0.7 + github.com/Roshick/go-autumn-slog v0.2.2 + github.com/StephanHCB/go-autumn-config-api v0.2.1 + github.com/StephanHCB/go-autumn-config-env v0.2.2 + github.com/StephanHCB/go-autumn-logging v0.3.0 + github.com/StephanHCB/go-autumn-restclient v0.8.0 + github.com/StephanHCB/go-autumn-restclient-circuitbreaker v0.4.1 + github.com/StephanHCB/go-autumn-restclient-circuitbreaker-prometheus v0.1.0 + github.com/StephanHCB/go-autumn-restclient-prometheus v0.1.2 + github.com/go-chi/chi/v5 v5.1.0 github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a - github.com/google/uuid v1.3.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.19.1 + github.com/stretchr/testify v1.9.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/stretchr/testify v1.7.1 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/sony/gobreaker v1.0.0 // indirect + github.com/tidwall/tinylru v1.2.1 // indirect + golang.org/x/sys v0.18.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) - diff --git a/go.sum b/go.sum index bd0706a..30e350d 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,77 @@ +github.com/Roshick/go-autumn-slog v0.2.2 h1:6qbGhzyWgdmBVXh0mTr39pjNAkPL9MthKNq1l/LB/F0= +github.com/Roshick/go-autumn-slog v0.2.2/go.mod h1:x9SmGQMayVv2y8MG2xZe5VImvm2qiP5N4BEOj1z68s4= +github.com/StephanHCB/go-autumn-config-api v0.2.1 h1:t2EeTsdFpLM2xH2T7QFQtbFYI8hG5I9S+Q2o3KT6mlk= +github.com/StephanHCB/go-autumn-config-api v0.2.1/go.mod h1:6nJBwuT1uURHApOSFr6Rw+naK2YkO+sAduwEWZ0qsSU= +github.com/StephanHCB/go-autumn-config-env v0.2.2 h1:DWal2O4gKlNsrKnq8V5nRNUevqkH2+xGT/BwCyqrV3I= +github.com/StephanHCB/go-autumn-config-env v0.2.2/go.mod h1:aTwlB8AVSnqGt4537uVHtZs9/3NEFYQwuVTRaNutgwg= +github.com/StephanHCB/go-autumn-logging v0.3.0 h1:G0zs8xoth8i8mOeoFgG3Dvk6dIY9dPPJ7wkm6mjaPyY= +github.com/StephanHCB/go-autumn-logging v0.3.0/go.mod h1:dPABYdECU3XrFib03uXbQFVLftUP5c4YaKSineiw37U= +github.com/StephanHCB/go-autumn-restclient v0.8.0 h1:USpjghjvAXA48P5H+BiZy9sKbETpvb49H84xcD6kgRs= +github.com/StephanHCB/go-autumn-restclient v0.8.0/go.mod h1:EoaVWjbNujPWo/XJZs7Pzsbb4R24a+J+A9gHB8arjGw= +github.com/StephanHCB/go-autumn-restclient-circuitbreaker v0.4.1 h1:aRs7bVnENGiKsWyvz6d5jAb2LuRkM1AUIzMoOgjzRLs= +github.com/StephanHCB/go-autumn-restclient-circuitbreaker v0.4.1/go.mod h1:ICUphrYTDMZ3HYZ8mEuFe0dZB163LBsmo1HtXaN10As= +github.com/StephanHCB/go-autumn-restclient-circuitbreaker-prometheus v0.1.0 h1:e/URQHOH0SMTPwK6JGbt1DeGS4QFUtBDky0wadMlieQ= +github.com/StephanHCB/go-autumn-restclient-circuitbreaker-prometheus v0.1.0/go.mod h1:Gc2lrqPVi938ZYzHdhcdy7NRQs5o2azDvvfnGBJRw/M= +github.com/StephanHCB/go-autumn-restclient-prometheus v0.1.2 h1:xsTZdR9WlSaoeUc3H6cuMJWizC2+zwE8VggOLn61zvI= +github.com/StephanHCB/go-autumn-restclient-prometheus v0.1.2/go.mod h1:74X7ghxTOjj6Tl0lvVJXOQHz+3RbKvGirvF5l82XDwk= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= -github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno= github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/tinylru v1.2.1 h1:VgBr72c2IEr+V+pCdkPZUwiQ0KJknnWIYbhxAVkYfQk= +github.com/tidwall/tinylru v1.2.1/go.mod h1:9bQnEduwB6inr2Y7AkBP7JPgCkyrhTV/ZpX0oOOpBI4= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apimodel/apimodel.go b/internal/apimodel/apimodel.go new file mode 100644 index 0000000..1ed46d5 --- /dev/null +++ b/internal/apimodel/apimodel.go @@ -0,0 +1,28 @@ +// Code generated by generate.sh using openapi-generator-cli. DO NOT EDIT. + +package apimodel + +import "time" + +var _ = time.Now + +type Error struct { + // The time at which the error occurred. + Timestamp time.Time `json:"timestamp"` + // An internal trace id assigned to the error. Used to find logs associated with errors across our services. Display to the user as something to communicate to us with inquiries about the error. + Requestid string `json:"requestid"` + // A keyed description of the error. We do not write human readable text here because the user interface will be multi language. At this time, there are these values: - auth.unauthorized (token missing completely or invalid) - auth.forbidden (permissions missing) + Message string `json:"message"` + // Optional additional details about the error. If available, will usually contain English language technobabble. + Details map[string][]string `json:"details,omitempty"` +} + +type Example struct { + // A random example value that is generated by the business logic. + Value int64 `json:"value"` +} + +type Health struct { + // the status of this service. If you get a response at all, status will be \"OK\". + Status string `json:"status"` +} diff --git a/internal/application/app/app.go b/internal/application/app/app.go new file mode 100644 index 0000000..d1ffe55 --- /dev/null +++ b/internal/application/app/app.go @@ -0,0 +1,126 @@ +package app + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/application/server" + "github.com/eurofurence/reg-backend-template-test/internal/controller/examplectl" + "github.com/eurofurence/reg-backend-template-test/internal/controller/infoctl" + "github.com/eurofurence/reg-backend-template-test/internal/repository/configuration" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + "github.com/eurofurence/reg-backend-template-test/internal/repository/logging" + "github.com/eurofurence/reg-backend-template-test/internal/repository/vault" + "github.com/eurofurence/reg-backend-template-test/internal/service/example" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/go-chi/chi/v5" +) + +// Application is the main application. +// +// It collects components, but only the ones that it actually needs to keep track of. +// +// Application is responsible for wiring up the application, so this is the place +// where you need to pay attention to dependencies between components. +// +// Note that individual components are expected to take care of logging. This code +// is supposed to be as easy as possible to read. +type Application struct { + // repositories + Vault vault.Vault + IDPClient idp.IdentityProviderClient + + // services + Example example.Example + + // controllers + + // servers +} + +func New() *Application { + return &Application{} +} + +func (a *Application) Run() int { + ctx := context.Background() + + if err := a.SetupConfigurationAndLogging(); err != nil { + return 1 + } + + if err := a.SetupRepositories(ctx); err != nil { + return 2 + } + + if err := a.SetupServices(ctx); err != nil { + return 3 + } + + router, err := server.Router(ctx, a.IDPClient) + if err != nil { + return 4 + } + + if err := a.SetupControllers(ctx, router); err != nil { + return 5 + } + + if err := server.Serve(ctx, router); err != nil { + return 6 + } + + return 0 +} + +func (a *Application) SetupConfigurationAndLogging() error { + logging.PreliminarySetup() + + if err := configuration.Setup(); err != nil { + aulogging.Logger.NoCtx().Error().WithErr(err).Print("failed to obtain configuration - BAILING OUT: %v", err) + return err + } + if err := logging.Setup(); err != nil { + aulogging.Logger.NoCtx().Error().WithErr(err).Print("failed to configure logging - BAILING OUT: %v", err) + return err + } + + aulogging.Logger.NoCtx().Info().Print("successfully obtained configuration and set up logging") + return nil +} + +func (a *Application) SetupRepositories(ctx context.Context) error { + if a.Vault == nil { + a.Vault = vault.New() + } + + if err := a.Vault.Authenticate(ctx); err != nil { + return err + } + if err := a.Vault.ObtainSecrets(ctx); err != nil { + return err + } + + if a.IDPClient == nil { + options := idp.OptionsFromConfig() + a.IDPClient = idp.New(options) + } + + if err := a.IDPClient.SetupFromWellKnown(ctx); err != nil { + return err + } + + return nil +} + +func (a *Application) SetupServices(ctx context.Context) error { + if a.Example == nil { + a.Example = example.New() + } + + return nil +} + +func (a *Application) SetupControllers(ctx context.Context, router chi.Router) error { + examplectl.InitRoutes(router, a.Example) + infoctl.InitRoutes(router) + return nil +} diff --git a/internal/application/common/context.go b/internal/application/common/context.go new file mode 100644 index 0000000..cad8ef3 --- /dev/null +++ b/internal/application/common/context.go @@ -0,0 +1,105 @@ +package common + +import ( + "context" + "github.com/golang-jwt/jwt/v5" +) + +type ( + CtxKeyIDToken struct{} + CtxKeyAccessToken struct{} + CtxKeyAPIKey struct{} + CtxKeyClaims struct{} + + CtxKeyRequestID struct{} +) + +type CustomClaims struct { + EMail string `json:"email"` + EMailVerified bool `json:"email_verified"` + Groups []string `json:"groups,omitempty"` + Name string `json:"name"` +} + +type AllClaims struct { + jwt.RegisteredClaims + CustomClaims +} + +// GetRequestID extracts the request ID from the context. +// +// It originally comes from a header with the request, or is rolled while processing +// the request. +func GetRequestID(ctx context.Context) string { + if ctx == nil { + return "00000000" + } + + if reqID, ok := ctx.Value(CtxKeyRequestID{}).(string); ok { + return reqID + } + + return "ffffffff" +} + +// GetAccessToken obtains the raw access token from the context. +// +// This is without the "Bearer " prefix. +func GetAccessToken(ctx context.Context) string { + if ctx == nil { + return "" + } + + if token, ok := ctx.Value(CtxKeyAccessToken{}).(string); ok { + return token + } + + return "" +} + +// GetClaims extracts all jwt token claims from the context. +func GetClaims(ctx context.Context) *AllClaims { + claims := ctx.Value(CtxKeyClaims{}) + if claims == nil { + return nil + } + + allClaims, ok := claims.(*AllClaims) + if !ok { + return nil + } + + return allClaims +} + +// GetGroups extracts the groups from the jwt token that came with the request +// or from the groups retrieved from userinfo, if using authorization token. +// +// In either case the list is filtered by relevant groups (if reg-auth-service is configured). +func GetGroups(ctx context.Context) []string { + claims := GetClaims(ctx) + if claims == nil || claims.Groups == nil { + return []string{} + } + return claims.Groups +} + +// HasGroup checks that the user has a group. +func HasGroup(ctx context.Context, group string) bool { + for _, grp := range GetGroups(ctx) { + if grp == group { + return true + } + } + return false +} + +// GetSubject extracts the subject field from the jwt token or the userinfo response, if using +// an authorization token. +func GetSubject(ctx context.Context) string { + claims := GetClaims(ctx) + if claims == nil { + return "" + } + return claims.Subject +} diff --git a/internal/application/common/errors.go b/internal/application/common/errors.go new file mode 100644 index 0000000..534ba0b --- /dev/null +++ b/internal/application/common/errors.go @@ -0,0 +1,135 @@ +package common + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "github.com/eurofurence/reg-backend-template-test/internal/repository/timestamp" + "net/http" + "net/url" +) + +// APIError allows lower layers of the service to provide detailed information about an error. +// +// While this breaks layer separation somewhat, it avoids having to map errors all over the place. +type APIError interface { + error + Status() int + Response() apimodel.Error +} + +// ErrorMessageCode is a key to use for error messages in frontends or other automated systems interacting +// with our API. It avoids having to parse human-readable language for error classification beyond the +// http status. +type ErrorMessageCode string + +const ( + AuthUnauthorized ErrorMessageCode = "auth.unauthorized" // token missing completely or invalid or expired + AuthForbidden ErrorMessageCode = "auth.forbidden" // permissions missing + RequestParseFailed ErrorMessageCode = "request.parse.failed" + ValueTooHigh ErrorMessageCode = "value.too.high" + ValueTooLow ErrorMessageCode = "value.too.low" + InternalErrorMessage ErrorMessageCode = "error.internal" + UnknownErrorMessage ErrorMessageCode = "error.unknown" +) + +// construct specific API errors + +func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusBadRequest, message, details) +} + +func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusUnauthorized, message, details) +} + +func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusForbidden, message, details) +} + +func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusNotFound, message, details) +} + +func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusConflict, message, details) +} + +func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusInternalServerError, message, details) +} + +func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values) APIError { + return NewAPIError(ctx, http.StatusBadGateway, message, details) +} + +// check for API errors + +func IsBadRequestError(err error) bool { + return isAPIErrorWithStatus(http.StatusBadRequest, err) +} + +func IsUnauthorizedError(err error) bool { + return isAPIErrorWithStatus(http.StatusUnauthorized, err) +} + +func IsForbiddenError(err error) bool { + return isAPIErrorWithStatus(http.StatusForbidden, err) +} + +func IsNotFoundError(err error) bool { + return isAPIErrorWithStatus(http.StatusNotFound, err) +} + +func IsConflictError(err error) bool { + return isAPIErrorWithStatus(http.StatusConflict, err) +} + +func IsBadGatewayError(err error) bool { + return isAPIErrorWithStatus(http.StatusBadGateway, err) +} + +func IsInternalServerError(err error) bool { + return isAPIErrorWithStatus(http.StatusInternalServerError, err) +} + +func IsAPIError(err error) bool { + _, ok := err.(APIError) + return ok +} + +// NewAPIError creates a generic API error from directly provided information. +func NewAPIError(ctx context.Context, status int, message ErrorMessageCode, details url.Values) APIError { + return &StatusError{ + errStatus: status, + response: apimodel.Error{ + Timestamp: timestamp.Now(), + Requestid: GetRequestID(ctx), + Message: string(message), + Details: details, + }, + } +} + +var _ error = (*StatusError)(nil) + +type StatusError struct { + errStatus int + response apimodel.Error +} + +func (se *StatusError) Error() string { + return se.response.Message +} + +func (se *StatusError) Status() int { + return se.errStatus +} + +func (se *StatusError) Response() apimodel.Error { + return se.response +} + +func isAPIErrorWithStatus(status int, err error) bool { + apiError, ok := err.(APIError) + return ok && status == apiError.Status() +} diff --git a/internal/application/middleware/cors.go b/internal/application/middleware/cors.go new file mode 100644 index 0000000..4b8f43e --- /dev/null +++ b/internal/application/middleware/cors.go @@ -0,0 +1,74 @@ +package middleware + +import ( + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/go-http-utils/headers" + "strings" + + "net/http" +) + +type CorsOptions struct { + Enable bool + AllowOrigin string + ExposeHeaders []string // Location, X-Request-Id, ... +} + +func CorsHeaders(options *CorsOptions) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if options != nil && options.Enable { + aulogging.Infof(ctx, "sending headers to disable CORS from %s", options.AllowOrigin) + w.Header().Set(headers.AccessControlAllowOrigin, options.AllowOrigin) + w.Header().Set(headers.AccessControlAllowMethods, "POST, GET, OPTIONS, PUT, DELETE") + w.Header().Set(headers.AccessControlAllowHeaders, "content-type") + w.Header().Set(headers.AccessControlAllowCredentials, "true") + w.Header().Set(headers.AccessControlExposeHeaders, strings.Join(options.ExposeHeaders, ", ")) + } + + if r.Method == http.MethodOptions { + aulogging.Info(ctx, "received OPTIONS request. Responding with OK.") + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +const ( + ConfCorsHeadersEnable = "CORS_HEADERS_ENABLE" + ConfCorsAllowOrigin = "CORS_ALLOW_ORIGIN" +) + +func CorsConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: ConfCorsHeadersEnable, + Default: "0", + Description: "enable CORS headers. Must also configure CORS_ALLOW_ORIGIN or this will not work. Set to '1' to enable.", + Validate: auconfigenv.ObtainUintRangeValidator(0, 1), + }, { + Key: ConfCorsAllowOrigin, + Default: "", + Description: "origin to allow via CORS headers. With recent browsers, '*' no longer works. Example: 'http://localhost:8000'.", + Validate: auconfigenv.ObtainPatternValidator("^(|https?://.*)$"), + }, + } +} + +func CorsOptionsFromConfig() CorsOptions { + return CorsOptions{ + Enable: auconfigenv.Get(ConfCorsHeadersEnable) == "1", + AllowOrigin: auconfigenv.Get(ConfCorsAllowOrigin), + ExposeHeaders: []string{ + "Location", + RequestIDHeader, + }, + } +} diff --git a/internal/application/middleware/recoverer.go b/internal/application/middleware/recoverer.go new file mode 100644 index 0000000..a7dd7bc --- /dev/null +++ b/internal/application/middleware/recoverer.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + aulogging "github.com/StephanHCB/go-autumn-logging" + "net/http" + "runtime/debug" +) + +func PanicRecoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + rvr := recover() + if rvr != nil && rvr != http.ErrAbortHandler { + ctx := r.Context() + stack := string(debug.Stack()) + aulogging.Error(ctx, "recovered from PANIC: "+stack) + web.SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "") + } + }() + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} diff --git a/internal/application/middleware/reqid.go b/internal/application/middleware/reqid.go new file mode 100644 index 0000000..34e04e6 --- /dev/null +++ b/internal/application/middleware/reqid.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "net/http" + "regexp" + + "github.com/google/uuid" +) + +var RequestIDHeader = "X-Request-Id" + +var ValidRequestIdRegex = regexp.MustCompile("^[0-9a-f]{8}$") + +// RequestID obtains the request id from the request header, or failing that, creates a new request id, +// and places it in the request context. +// +// It also adds it to the response under the same header. +// +// This automatically also leads to all logging using this context to log the request id. +func RequestID(next http.Handler) http.Handler { + handlerFunc := func(w http.ResponseWriter, r *http.Request) { + reqUuidStr := r.Header.Get(RequestIDHeader) + if !ValidRequestIdRegex.MatchString(reqUuidStr) { + reqUuid, err := uuid.NewRandom() + if err == nil { + reqUuidStr = reqUuid.String()[:8] + } else { + // this should not normally ever happen, but continue with this fixed requestId + reqUuidStr = "ffffffff" + } + } + ctx := r.Context() + newCtx := context.WithValue(ctx, common.CtxKeyRequestID{}, reqUuidStr) + r = r.WithContext(newCtx) + w.Header().Add(RequestIDHeader, reqUuidStr) + + next.ServeHTTP(w, r) + } + return http.HandlerFunc(handlerFunc) +} diff --git a/internal/application/middleware/requestlogging.go b/internal/application/middleware/requestlogging.go new file mode 100644 index 0000000..6134945 --- /dev/null +++ b/internal/application/middleware/requestlogging.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "github.com/Roshick/go-autumn-slog/pkg/logging" + "log/slog" + "net/http" + "time" + + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/go-chi/chi/v5/middleware" +) + +var NanosFieldName = "event.duration" +var StatusFieldName = "http.response.status_code" + +// RequestLogger logs each incoming request with a single line. +// +// Place it below RequestIdMiddleware and the log line will include the request id. +func RequestLogger(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + method := r.Method + path := r.URL.EscapedPath() + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + start := time.Now() + aulogging.Debugf(ctx, "received request %s %s", method, path) + + defer func() { + elapsed := time.Since(start).Nanoseconds() + status := ww.Status() + + logger := logging.FromContext(ctx) + logger = logger.With(NanosFieldName, elapsed, StatusFieldName, status) + newCtx := logging.ContextWithLogger(ctx, logger) + aulogging.Infof(newCtx, "request %s %s -> %d (%d ms)", method, path, status, elapsed/1000000) + }() + + next.ServeHTTP(ww, r) + } + + return http.HandlerFunc(fn) +} + +var RequestIdFieldName = "http.request.id" +var MethodFieldName = "http.request.method" +var PathFieldName = "url.path" + +// AddRequestScopedLoggerToContext adds a request scoped logger to the context. +// +// Place it below RequestIdMiddleware and the logger will include the request id. +// +// This ensures all intermediate log messages also list the request id, request method, +// and path. If this middleware isn't present, then only the +func AddRequestScopedLoggerToContext(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logger := slog.Default().With( + MethodFieldName, r.Method, + PathFieldName, r.URL.Path, + ) + + if aulogging.RequestIdRetriever != nil { + requestId := aulogging.RequestIdRetriever(ctx) + logger = logger.With(RequestIdFieldName, requestId) + } + + newCtx := logging.ContextWithLogger(ctx, logger) + + next.ServeHTTP(w, r.WithContext(newCtx)) + } + return http.HandlerFunc(fn) +} diff --git a/internal/application/middleware/requestmetrics.go b/internal/application/middleware/requestmetrics.go new file mode 100644 index 0000000..bafae7c --- /dev/null +++ b/internal/application/middleware/requestmetrics.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "fmt" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus" + "net/http" + "strings" + "time" +) + +var ( + RequestCounterName = "http_server_requests_seconds_count" + RequestDurationName = "http_server_requests_seconds_sum" + + reqs *prometheus.CounterVec + latency *prometheus.SummaryVec +) + +func RequestMetrics() func(http.Handler) http.Handler { + if reqs == nil { + reqs = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: RequestCounterName, + Help: "Number of incoming HTTP requests processed, partitioned by status code, method and HTTP path (grouped by patterns).", + }, + []string{"method", "outcome", "status", "uri"}, + ) + prometheus.MustRegister(reqs) + } + + if latency == nil { + latency = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: RequestDurationName, + Help: "How long it took to process requests, partitioned by status code, method and HTTP path (grouped by patterns).", + }, + []string{"method", "outcome", "status", "uri"}, + ) + prometheus.MustRegister(latency) + } + + return recordRequestMetrics +} + +func recordRequestMetrics(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + + rctx := chi.RouteContext(r.Context()) + routePattern := strings.Join(rctx.RoutePatterns, "") + routePattern = strings.Replace(routePattern, "/*/", "/", -1) + + reqs.WithLabelValues(r.Method, outcome(ww.Status()), fmt.Sprintf("%d", ww.Status()), routePattern).Inc() + latency.WithLabelValues(r.Method, outcome(ww.Status()), fmt.Sprintf("%d", ww.Status()), routePattern).Observe(float64(time.Since(start).Microseconds()) / 1000000) + }) +} + +func outcome(status int) string { + if status < 400 { + return "SUCCESS" + } else if status < 500 { + return "CLIENT_ERROR" + } else { + return "SERVER_ERROR" + } +} diff --git a/internal/application/middleware/security.go b/internal/application/middleware/security.go new file mode 100644 index 0000000..1867a99 --- /dev/null +++ b/internal/application/middleware/security.go @@ -0,0 +1,298 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/golang-jwt/jwt/v5" + "net/http" + "strings" + + "github.com/go-http-utils/headers" +) + +type SecurityOptions struct { + // OpenEndpoints is the list of endpoints that can be accessed without requiring a valid token or api key + // + // Any endpoint not in this list will reject access. + // + // Even if an endpoint is in this list, if a token or api key is present, they are still validated to catch + // expired or retracted credentials. The endpoint may behave differently if authorization is presented, after all. + // + // Format: METHOD PATH + // Example: GET / + OpenEndpoints []string + + // ApiKey is a fixed shared secret. It uses a separate header so this can be filtered in an ingress. + ApiKey string + + // OpenID Connect, these may be extracted from the .well-known endpoint during middleware setup + IDPClient idp.IdentityProviderClient + + AllowedAudiences []string + RequiredScopes []string +} + +const ( + ConfOIDCAllowedAudiences = "OIDC_ALLOWED_AUDIENCES" + ConfOIDCRequiredScopes = "OIDC_REQUIRED_SCOPES" + ConfApiKey = "API_KEY" + ConfOpenEndpoints = "OPEN_ENDPOINTS" +) + +func SecurityConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: ConfOIDCAllowedAudiences, + Default: "", + Description: "Audiences of the token to allow through. A token is authorized if its audience is in this list. Will need a token introspection endpoint to be present in OpenID Well Known response. Accepts a space separated list. If empty, all audiences are allowed through (may not be secure).", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: ConfOIDCRequiredScopes, + Default: "", + Description: "Scopes of the token to allow through. A token is authorized only if it has all scopes in this list. Will need a token introspection endpoint to be present in OpenID Well Known response. Accepts a space separated list. If empty, no scopes are checked (may not be secure).", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: ConfApiKey, + Default: "", + Description: "Shared secret API Key. Uses a separate header, which should be filtered in the ingress to prevent use from the outside.", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: ConfOpenEndpoints, + Default: `["GET /", "GET /favicon.ico"]`, + Description: "List of endpoints which can be called without authorization.", + Validate: validateOpenEndpoints, + }, + } +} + +func parseOpenEndpoints(value string) ([]string, error) { + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.DisallowUnknownFields() + result := make([]string, 0) + err := decoder.Decode(&result) + if err != nil { + return result, fmt.Errorf("failed to parse open endpoint configuration: %w", err) + } + return result, nil +} + +func validateOpenEndpoints(key string) error { + val := auconfigenv.Get(key) + _, err := parseOpenEndpoints(val) + return err +} + +func SecurityOptionsPartialFromConfig() SecurityOptions { + openEndpoints, _ := parseOpenEndpoints(auconfigenv.Get(ConfOpenEndpoints)) + return SecurityOptions{ + ApiKey: auconfigenv.Get(ConfApiKey), + AllowedAudiences: splitBySpaceOrEmpty(auconfigenv.Get(ConfOIDCAllowedAudiences)), + RequiredScopes: splitBySpaceOrEmpty(auconfigenv.Get(ConfOIDCRequiredScopes)), + OpenEndpoints: openEndpoints, + } +} + +func splitBySpaceOrEmpty(value string) []string { + if value == "" { + return nil + } + return strings.Split(value, " ") +} + +const ( + apiKeyHeader = "X-Api-Key" + bearerPrefix = "Bearer " +) + +// CheckRequestAuthorization creates a middleware that validates authorization and adds them to the relevant +// context values. +func CheckRequestAuthorization(conf *SecurityOptions) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + apiTokenHeaderValue := fromApiTokenHeader(r) + authHeaderValue := fromAuthHeader(r) + + ctx, userFacingErrorMessage, err := checkAllAuthentication(ctx, r.Method, r.URL.Path, conf, apiTokenHeaderValue, authHeaderValue) + if err != nil { + subject := common.GetSubject(ctx) + aulogging.InfoErrf(ctx, err, "authorization failed for subject %s: %s", subject, userFacingErrorMessage) + web.SendUnauthorizedResponse(ctx, w, userFacingErrorMessage) + return + } + + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) + } +} + +// internals + +// --- getting the values from the request --- + +func fromAuthHeader(r *http.Request) string { + headerValue := r.Header.Get(headers.Authorization) + + if !strings.HasPrefix(headerValue, bearerPrefix) { + return "" + } + + return strings.TrimPrefix(headerValue, bearerPrefix) +} + +func fromApiTokenHeader(r *http.Request) string { + return r.Header.Get(apiKeyHeader) +} + +// --- top level ---. + +func checkAllAuthentication(ctx context.Context, method string, urlPath string, conf *SecurityOptions, apiTokenHeaderValue string, authHeaderValue string) (context.Context, string, error) { + var success bool + var err error + + // try api token first + ctx, success, err = checkApiToken(ctx, conf, apiTokenHeaderValue) + if err != nil { + return ctx, "invalid api token", err + } + if success { + return ctx, "", nil + } + + // now try authorization header (gives only access token, so MUST use userinfo/tokeninfo endpoint) + ctx, success, err = checkAccessToken(ctx, conf, authHeaderValue) + if err != nil { + return ctx, "invalid bearer token", err + } + if success { + return ctx, "", nil + } + + // allow through (but still AFTER auth processing) + currentEndpoint := fmt.Sprintf("%s %s", method, urlPath) + for _, publicEndpoint := range conf.OpenEndpoints { + if currentEndpoint == publicEndpoint { + return ctx, "", nil + } + } + + return ctx, "you must be logged in for this operation", errors.New("no authorization presented") +} + +// --- validating the individual pieces --- + +// important - if any of these return an error, you must abort processing via "return" and log the error message + +func checkApiToken(ctx context.Context, conf *SecurityOptions, apiTokenValue string) (context.Context, bool, error) { + if apiTokenValue != "" { + // ignore jwt if set (may still need to pass it through to other service) + if apiTokenValue == conf.ApiKey { + ctx = context.WithValue(ctx, common.CtxKeyAPIKey{}, apiTokenValue) + return ctx, true, nil + } else { + return ctx, false, errors.New("token doesn't match the configured value") + } + } + return ctx, false, nil +} + +func checkAccessToken(ctx context.Context, conf *SecurityOptions, accessTokenValue string) (context.Context, bool, error) { + if accessTokenValue != "" { + if conf.IDPClient != nil { + authCtx := context.WithValue(ctx, common.CtxKeyAccessToken{}, accessTokenValue) // need this set for userinfo call + + userInfo, status, err := conf.IDPClient.UserInfo(authCtx) + if err != nil { + return ctx, false, fmt.Errorf("request failed access token check, denying: %s", err.Error()) + } + if status != http.StatusOK { + return ctx, false, fmt.Errorf("request failed access token check with status %d, denying", status) + } + + if len(conf.AllowedAudiences) > 0 { + if !listsIntersect(conf.AllowedAudiences, userInfo.Audience) { + return ctx, false, errors.New("token audience does not contain a match") + } + } + + if len(conf.RequiredScopes) > 0 { + tokenInfo, status, err := conf.IDPClient.TokenIntrospection(ctx) + if err != nil { + return ctx, false, fmt.Errorf("request failed access token introspection, denying: %s", err.Error()) + } + if status != http.StatusOK { + return ctx, false, fmt.Errorf("request failed access token introspection with status %d, denying", status) + } + + if !listsContained(strings.Split(tokenInfo.Scope, " "), conf.RequiredScopes) { + return ctx, false, errors.New("token does not have all required scopes") + } + } + + overwriteClaims := common.AllClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: conf.IDPClient.Issuer(), + Subject: userInfo.Subject, + Audience: jwt.ClaimStrings(userInfo.Audience), + }, + CustomClaims: common.CustomClaims{ + EMail: userInfo.Email, + EMailVerified: userInfo.EmailVerified, + Groups: userInfo.Groups, + Name: userInfo.Name, + }, + } + + ctx = context.WithValue(authCtx, common.CtxKeyClaims{}, &overwriteClaims) + return ctx, true, nil + } else { + return ctx, false, errors.New("request failed access token check, denying: no userinfo endpoint configured") + } + } + return ctx, false, nil +} + +// listsIntersect is true if at least one common element exists. +// +// If either list is empty, they do not intersect. +func listsIntersect(firstList []string, secondList []string) bool { + for _, first := range firstList { + for _, second := range secondList { + if first == second { + return true + } + } + } + return false +} + +// listsContained is true if all needles are in the haystack. +// +// An empty list of needles is considered contained in any haystack. +func listsContained(haystack []string, needles []string) bool { + for _, needle := range needles { + if !listContains(haystack, needle) { + return false + } + } + return true +} + +func listContains(haystack []string, needle string) bool { + for _, candidate := range haystack { + if needle == candidate { + return true + } + } + return false +} diff --git a/internal/application/middleware/security_test.go b/internal/application/middleware/security_test.go new file mode 100644 index 0000000..d5b4385 --- /dev/null +++ b/internal/application/middleware/security_test.go @@ -0,0 +1,193 @@ +package middleware + +import ( + "context" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestCheckAllAuthentication(t *testing.T) { + origCtx := context.WithValue(context.Background(), "ctxkey", "ctxval") + checkOrigCtx := func(ctx context.Context) bool { + return ctx.Value("ctxkey") == "ctxval" + } + compareErr := func(msg string) func(error) bool { + return func(err error) bool { + return err.Error() == msg + } + } + + configNoIDP := SecurityOptions{ + OpenEndpoints: []string{ + "POST a/b/open", + "PUT open/a/b", + }, + ApiKey: "api-key", + IDPClient: nil, + AllowedAudiences: nil, + RequiredScopes: nil, + } + + testcases := []struct { + name string + method string + urlPath string + conf *SecurityOptions + apiToken string + authHeader string + expectCtx func(context.Context) bool + expectMsg string + expectErr func(error) bool + }{ + { + name: "no_idp_none_provided", + method: http.MethodGet, + urlPath: "a/b/c", + conf: &configNoIDP, + apiToken: "", + authHeader: "", + expectCtx: checkOrigCtx, + expectMsg: "you must be logged in for this operation", + expectErr: compareErr("no authorization presented"), + }, + // TODO more test cases with mocked idp client now + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ctx, msg, err := checkAllAuthentication(origCtx, tc.method, tc.urlPath, tc.conf, tc.apiToken, tc.authHeader) + require.True(t, tc.expectCtx(ctx)) + require.Equal(t, tc.expectMsg, msg) + require.True(t, tc.expectErr(err)) + }) + } +} + +func TestListsIntersect(t *testing.T) { + testcases := []struct { + name string + first []string + second []string + expected bool + }{ + { + name: "both_nil", + first: nil, + second: nil, + expected: false, + }, + { + name: "first_empty", + first: []string{}, + second: []string{"a", "b"}, + expected: false, + }, + { + name: "second_empty", + first: []string{"a", "b"}, + second: []string{}, + expected: false, + }, + { + name: "identical", + first: []string{"d"}, + second: []string{"d"}, + expected: true, + }, + { + name: "intersect", + first: []string{"a", "b", "c", "d"}, + second: []string{"d", "e", "f", "g"}, + expected: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, listsIntersect(tc.first, tc.second)) + }) + } +} + +func TestListsContained(t *testing.T) { + testcases := []struct { + name string + haystack []string + needles []string + expected bool + }{ + { + name: "both_nil", + haystack: nil, + needles: nil, + expected: true, + }, + { + name: "needles_empty", + haystack: []string{"a", "b", "c"}, + needles: []string{}, + expected: true, + }, + { + name: "not_contained", + haystack: []string{"a", "b"}, + needles: []string{"a", "d"}, + expected: false, + }, + { + name: "contained", + haystack: []string{"a", "b", "c", "d"}, + needles: []string{"a", "d"}, + expected: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, listsContained(tc.haystack, tc.needles)) + }) + } +} + +func TestListContains(t *testing.T) { + testcases := []struct { + name string + haystack []string + needle string + expected bool + }{ + { + name: "nil_stack", + haystack: nil, + needle: "a", + expected: false, + }, + { + name: "empty_stack", + haystack: []string{}, + needle: "a", + expected: false, + }, + { + name: "single_stack", + haystack: []string{"a"}, + needle: "a", + expected: true, + }, + { + name: "multi_stack", + haystack: []string{"b", "a", "d"}, + needle: "a", + expected: true, + }, + { + name: "not_contained", + haystack: []string{"b", "f", "x"}, + needle: "a", + expected: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, listContains(tc.haystack, tc.needle)) + }) + } +} diff --git a/internal/application/middleware/timeout.go b/internal/application/middleware/timeout.go new file mode 100644 index 0000000..937ce7e --- /dev/null +++ b/internal/application/middleware/timeout.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "context" + "net/http" + "time" +) + +func Timeout(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/application/server/lowlevel.go b/internal/application/server/lowlevel.go new file mode 100644 index 0000000..570b9fe --- /dev/null +++ b/internal/application/server/lowlevel.go @@ -0,0 +1,136 @@ +package server + +import ( + "context" + "fmt" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/prometheus/client_golang/prometheus/promhttp" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/pkg/errors" +) + +type Server interface { + Serve(handler http.Handler) error + Shutdown() error +} + +type Options struct { + BaseCtx context.Context + + Host string + Port int + MetricsPort int + + IdleTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + + ShutdownWait time.Duration +} + +type server struct { + options Options + + srv *http.Server + metricsSrv *http.Server + + interrupt chan os.Signal + shutdown chan struct{} +} + +var _ Server = (*server)(nil) + +func NewServer(options Options) Server { + s := new(server) + + s.interrupt = make(chan os.Signal, 1) + s.shutdown = make(chan struct{}) + + s.options = options + + return s +} + +func (s *server) Serve(handler http.Handler) error { + s.srv = s.newServer(handler, s.options.Port) + + if s.options.MetricsPort > 0 { + go s.serveMetricsAsync(s.options.MetricsPort) + } + + s.setupSignalHandler() + go s.handleInterrupt() + + aulogging.Logger.NoCtx().Info().Printf("serving requests on %s...", s.srv.Addr) + if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + <-s.shutdown + + return nil +} + +func (s *server) serveMetricsAsync(port int) { + metricsServeMux := http.NewServeMux() + metricsServeMux.Handle("/metrics", promhttp.Handler()) + + s.metricsSrv = s.newServer(metricsServeMux, port) + + aulogging.Logger.NoCtx().Info().Printf("serving metrics requests on %s...", s.metricsSrv.Addr) + if err := s.metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + aulogging.Logger.NoCtx().Error().Printf("failed to start metrics service on %s...", s.metricsSrv.Addr) + return + } +} + +func (s *server) newServer(handler http.Handler, port int) *http.Server { + return &http.Server{ + BaseContext: func(l net.Listener) context.Context { + return s.options.BaseCtx + }, + Handler: handler, + IdleTimeout: s.options.IdleTimeout, + ReadTimeout: s.options.ReadTimeout, + WriteTimeout: s.options.WriteTimeout, + Addr: fmt.Sprintf("%s:%d", s.options.Host, port), + } +} + +func (s *server) setupSignalHandler() { + s.interrupt = make(chan os.Signal) + signal.Notify(s.interrupt, syscall.SIGINT, syscall.SIGTERM) +} + +func (s *server) handleInterrupt() { + <-s.interrupt + if err := s.Shutdown(); err != nil { + log.Fatal(err) + } +} + +func (s *server) Shutdown() error { + defer close(s.shutdown) + + aulogging.Logger.NoCtx().Info().Print("gracefully shutting down server") + + tCtx, cancel := context.WithTimeout(s.options.BaseCtx, s.options.ShutdownWait) + defer cancel() + + if err := s.srv.Shutdown(tCtx); err != nil { + return errors.Wrap(err, "couldn't gracefully shut down server") + } + if s.options.MetricsPort > 0 { + if err := s.metricsSrv.Shutdown(tCtx); err != nil { + return errors.Wrap(err, "couldn't gracefully shut down metrics server") + } + } + + return nil +} diff --git a/internal/application/server/server.go b/internal/application/server/server.go new file mode 100644 index 0000000..8a96a0e --- /dev/null +++ b/internal/application/server/server.go @@ -0,0 +1,144 @@ +package server + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/application/middleware" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/go-chi/chi/v5" + "net/http" + "time" +) + +func Router(ctx context.Context, idpClient idp.IdentityProviderClient) (chi.Router, error) { + router := chi.NewMux() + + err := setupMiddlewareStack(ctx, router, idpClient) + if err != nil { + return nil, err + } + + return router, nil +} + +func Serve(ctx context.Context, handler http.Handler) error { + options := Options{ + BaseCtx: ctx, + Host: auconfigenv.Get(ConfServerAddress), + Port: aToPort(auconfigenv.Get(ConfServerPort), 8080), + MetricsPort: aToPort(auconfigenv.Get(ConfMetricsPort), 0), + IdleTimeout: aToSeconds(auconfigenv.Get(ConfServerIdleTimeoutSeconds)), + ReadTimeout: aToSeconds(auconfigenv.Get(ConfServerReadTimeoutSeconds)), + WriteTimeout: aToSeconds(auconfigenv.Get(ConfServerWriteTimeoutSeconds)), + ShutdownWait: aToSeconds(auconfigenv.Get(ConfServerShutdownGraceSeconds)), + } + + srv := NewServer(options) + + err := srv.Serve(handler) + if err != nil { + aulogging.ErrorErrf(ctx, err, "failure during serve phase - shutting down: %s", err.Error()) + return err + } + + return nil +} + +func aToPort(s string, fallback int) int { + port, err := auconfigenv.AToInt(s) + if err != nil { + // config was validated so should only happen in tests, but use sensible value + return fallback + } + return port +} + +func aToSeconds(s string) time.Duration { + secs, err := auconfigenv.AToInt(s) + if err != nil { + // config was validated so should only happen in tests, but use sensible value + secs = 30 + } + return time.Duration(secs) * time.Second +} + +const ( + ConfServerAddress = "SERVER_ADDRESS" + ConfServerPort = "SERVER_PORT" + ConfMetricsPort = "METRICS_PORT" + ConfServerIdleTimeoutSeconds = "SERVER_IDLE_TIMEOUT_SECONDS" + ConfServerReadTimeoutSeconds = "SERVER_READ_TIMEOUT_SECONDS" + ConfServerWriteTimeoutSeconds = "SERVER_WRITE_TIMEOUT_SECONDS" + ConfServerShutdownGraceSeconds = "SERVER_SHUTDOWN_GRACE_SECONDS" + ConfRequestTimeoutSeconds = "REQUEST_TIMEOUT_SECONDS" +) + +func ConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: ConfServerAddress, + Default: "", + Description: "ip address or hostname to listen on, can be left blank for localhost", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: ConfServerPort, + Default: "8080", + Description: "port to listen on, defaults to 8080 if not set", + Validate: auconfigenv.ObtainUintRangeValidator(1024, 65535), + }, { + Key: ConfMetricsPort, + Default: "9090", + Description: "port to provide prometheus metrics on, cannot be a privileged port.", + Validate: auconfigenv.ObtainUintRangeValidator(1024, 65535), + }, { + Key: ConfServerIdleTimeoutSeconds, + Default: "60", + Description: "request processing timeout in seconds while connection is idle.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, { + Key: ConfServerReadTimeoutSeconds, + Default: "30", + Description: "request processing timeout in seconds during read.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, { + Key: ConfServerWriteTimeoutSeconds, + Default: "30", + Description: "request processing timeout in seconds while writing response.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, { + Key: ConfServerShutdownGraceSeconds, + Default: "3", + Description: "grace period in seconds for requests to finish processing while a graceful shutdown is under way.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, { + Key: ConfRequestTimeoutSeconds, + Default: "25", + Description: "request processing timeout in seconds. Allows for a proper error response to be sent, so should be set lower than the server timeouts in most common cases.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, + } +} + +func setupMiddlewareStack(ctx context.Context, router chi.Router, idpClient idp.IdentityProviderClient) error { + router.Use(middleware.RequestID) + + router.Use(middleware.AddRequestScopedLoggerToContext) + router.Use(middleware.RequestLogger) + + router.Use(middleware.PanicRecoverer) + + corsOptions := middleware.CorsOptionsFromConfig() + router.Use(middleware.CorsHeaders(&corsOptions)) + + router.Use(middleware.RequestMetrics()) + + securityOptions := middleware.SecurityOptionsPartialFromConfig() + securityOptions.IDPClient = idpClient + router.Use(middleware.CheckRequestAuthorization(&securityOptions)) + + router.Use(middleware.Timeout(aToSeconds(auconfigenv.Get(ConfRequestTimeoutSeconds)))) + + return nil +} diff --git a/internal/application/web/endpoint.go b/internal/application/web/endpoint.go new file mode 100644 index 0000000..7453b5c --- /dev/null +++ b/internal/application/web/endpoint.go @@ -0,0 +1,60 @@ +package web + +import ( + "context" + aulogging "github.com/StephanHCB/go-autumn-logging" + "net/http" +) + +type CtxKeyRequestURL struct{} + +type ( + RequestHandler[Req any] func(r *http.Request, w http.ResponseWriter) (*Req, error) + ResponseHandler[Res any] func(ctx context.Context, res *Res, w http.ResponseWriter) error + Endpoint[Req, Res any] func(ctx context.Context, request *Req, w http.ResponseWriter) (*Res, error) +) + +func CreateHandler[Req, Res any](endpoint Endpoint[Req, Res], + requestHandler RequestHandler[Req], + responseHandler ResponseHandler[Res], +) http.Handler { + if endpoint == nil { + panic("unable to set up service: no endpoint provided") + } + + if requestHandler == nil { + panic("unable to set up service: request handler must not be nil") + } + + if responseHandler == nil { + panic("unable to set up service: response handler must not be nil") + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, CtxKeyRequestURL{}, r.URL) + + defer func() { + err := r.Body.Close() + if err != nil { + aulogging.ErrorErrf(ctx, err, "Error when closing the request body. [error]: %v", err) + } + }() + + request, err := requestHandler(r, w) + if err != nil { + aulogging.ErrorErrf(ctx, err, "An error occurred while parsing the request. [error]: %v", err) + return + } + + response, err := endpoint(ctx, request, w) + if err != nil { + aulogging.ErrorErrf(ctx, err, "An error occurred during the request. [error]: %v", err) + return + } + + if err := responseHandler(ctx, response, w); err != nil { + aulogging.ErrorErrf(ctx, err, "An error occurred during the handling of the response. [error]: %v", err) + } + }) +} diff --git a/internal/application/web/endpoint_test.go b/internal/application/web/endpoint_test.go new file mode 100644 index 0000000..43b1e50 --- /dev/null +++ b/internal/application/web/endpoint_test.go @@ -0,0 +1,137 @@ +package web + +import ( + "context" + "fmt" + "github.com/go-chi/chi/v5" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testRequest struct { + Counter int +} + +type testResponse struct { + Counter int +} + +func setupHandler(ep Endpoint[testRequest, testResponse], rh RequestHandler[testRequest], resph ResponseHandler[testResponse]) http.Handler { + return CreateHandler(ep, rh, resph) +} + +func TestCreateHandler(t *testing.T) { + tReq := &testRequest{ + Counter: 0, + } + tRes := &testResponse{ + Counter: 0, + } + + tests := []struct { + name string + endpoint Endpoint[testRequest, testResponse] + reqHandler RequestHandler[testRequest] + respHandler ResponseHandler[testResponse] + shouldPanic bool + expectedError error + expectedRequestCounter int + expectedResponseCounter int + }{ + { + name: "Should panic when no request handler was provided", + reqHandler: nil, + endpoint: nil, + respHandler: func(ctx context.Context, res *testResponse, w http.ResponseWriter) error { + res.Counter++ + return nil + }, + shouldPanic: true, + }, + { + name: "Should panic when no response handler was provided", + endpoint: func(ctx context.Context, request *testRequest, w http.ResponseWriter) (*testResponse, error) { + return tRes, nil + }, + reqHandler: func(r *http.Request, w http.ResponseWriter) (*testRequest, error) { + return tReq, nil + }, + shouldPanic: true, + respHandler: nil, + }, + { + name: "Should panic when no endpoint was provided", + reqHandler: func(r *http.Request, w http.ResponseWriter) (*testRequest, error) { + return tReq, nil + }, + shouldPanic: true, + respHandler: func(ctx context.Context, res *testResponse, w http.ResponseWriter) error { + return nil + }, + }, + { + name: "Should increase counter when all values are set", + endpoint: func(ctx context.Context, request *testRequest, w http.ResponseWriter) (*testResponse, error) { + return tRes, nil + }, + reqHandler: func(r *http.Request, w http.ResponseWriter) (*testRequest, error) { + tReq.Counter++ + return tReq, nil + }, + respHandler: func(ctx context.Context, res *testResponse, w http.ResponseWriter) error { + res.Counter++ + return nil + }, + expectedRequestCounter: 1, + expectedResponseCounter: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tReq.Counter = 0 + tRes.Counter = 0 + router := chi.NewRouter() + + if tc.shouldPanic { + require.Panics(t, func() { + setupHandler(tc.endpoint, tc.reqHandler, tc.respHandler) + }) + + // stop execution of test logic + return + } + + router.Method(http.MethodGet, "/", setupHandler(tc.endpoint, tc.reqHandler, tc.respHandler)) + + srv := httptest.NewServer(router) + defer srv.Close() + + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, fmt.Sprintf("%s/", srv.URL), nil) + require.NoError(t, err) + + cl := &http.Client{ + Timeout: time.Second * 10, + } + + resp, err := cl.Do(req) + require.NoError(t, err) + + require.NotNil(t, resp) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, resp.Body.Close()) + require.NoError(t, err) + + fmt.Println(string(b)) + + require.Equal(t, tc.expectedRequestCounter, tReq.Counter) + require.Equal(t, tc.expectedResponseCounter, tRes.Counter) + }) + } +} diff --git a/internal/application/web/response.go b/internal/application/web/response.go new file mode 100644 index 0000000..f1c1aa6 --- /dev/null +++ b/internal/application/web/response.go @@ -0,0 +1,84 @@ +package web + +import ( + "context" + "encoding/json" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/pkg/errors" + "net/http" + "net/url" +) + +func EncodeToJSON(ctx context.Context, w http.ResponseWriter, obj interface{}) { + enc := json.NewEncoder(w) + + if obj != nil { + err := enc.Encode(obj) + if err != nil { + aulogging.ErrorErrf(ctx, err, "Could not encode response. [error]: %v", err) + } + } +} + +// SendErrorResponse will send HTTPStatusErrorResponse if err is common.APIError. +// +// Otherwise sends internal server error. +func SendErrorResponse(ctx context.Context, w http.ResponseWriter, err error) { + if err == nil { + aulogging.ErrorErrf(ctx, err, "nil error in web layer") + SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "an unspecified error occurred. Please check the logs - this is a bug") + return + } + + apiErr, ok := err.(common.APIError) + if !ok { + aulogging.ErrorErrf(ctx, err, "unwrapped error in web layer: %s", err.Error()) + SendErrorWithStatusAndMessage(ctx, w, http.StatusInternalServerError, common.InternalErrorMessage, "an unclassified error occurred. Please check the logs - this is a bug") + return + } + SendAPIErrorResponse(ctx, w, apiErr) +} + +// SendAPIErrorResponse will send an api error +// which contains relevant information about the failed request to the client. +// The function will also set the http status according to the provided status. +func SendAPIErrorResponse(ctx context.Context, w http.ResponseWriter, apiErr common.APIError) { + w.WriteHeader(apiErr.Status()) + + EncodeToJSON(ctx, w, apiErr.Response()) +} + +// SendErrorWithStatusAndMessage will construct an api error +// which contains relevant information about the failed request to the client +// The function will also set the http status according to the provided status. +func SendErrorWithStatusAndMessage(ctx context.Context, w http.ResponseWriter, status int, message common.ErrorMessageCode, details string) { + var detailValues url.Values + if details != "" { + aulogging.Debugf(ctx, "Request was not successful: [error]: %s", details) + detailValues = url.Values{"details": []string{details}} + } + + apiErr := common.NewAPIError(ctx, status, message, detailValues) + SendAPIErrorResponse(ctx, w, apiErr) +} + +// EncodeWithStatus will attempt to encode the provided `value` into the +// response writer `w` and will write the status header. +// If the encoding fails, the http status will not be written to the response writer +// and the function will return an error instead. +func EncodeWithStatus[T any](status int, value *T, w http.ResponseWriter) error { + err := json.NewEncoder(w).Encode(value) + if err != nil { + return errors.Wrap(err, "could not encode type into response buffer") + } + + w.WriteHeader(status) + + return nil +} + +// SendUnauthorizedResponse sends a standardized StatusUnauthorized response to the client. +func SendUnauthorizedResponse(ctx context.Context, w http.ResponseWriter, details string) { + SendErrorWithStatusAndMessage(ctx, w, http.StatusUnauthorized, common.AuthUnauthorized, details) +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 7e8a31d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,40 +0,0 @@ -// In general, configs are loaded via yaml files, that will be placed on the server. -// Therefore implement handling to load yaml from a file and validate the config based on rules -// which apply for the corresponding service implementation. - -package config - -import ( - "os" - // "gopkg.in/yaml.v2" -) - -type ( - Application struct { - Service ServiceConfig `yaml:"serviceConfig"` - IsCorsDisabled bool `yaml:"corsDisabled"` - } - - ServiceConfig struct { - ServiceName string `yaml:"serviceName"` - Port int `env:"servicePort"` - } -) - -func UnmarshalFromYamlConfiguration(file *os.File) (*Application, error) { - - // Load configuration from a yaml file - - // example: - // defer file.Close() - // d := yaml.NewDecoder(file) - // - // config := &Config{} - // if err := d.Decode(&config); err != nil { - // return nil, err - // } - - // return config, nil - - return nil, nil -} diff --git a/internal/controller/examplectl/examplectl.go b/internal/controller/examplectl/examplectl.go new file mode 100644 index 0000000..0b9cf56 --- /dev/null +++ b/internal/controller/examplectl/examplectl.go @@ -0,0 +1,50 @@ +package examplectl + +import ( + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "github.com/eurofurence/reg-backend-template-test/internal/service/example" + "github.com/go-chi/chi/v5" + "net/http" +) + +const categoryParam = "category" + +type Controller struct { + svc example.Example +} + +func InitRoutes(router chi.Router, svc example.Example) { + h := &Controller{ + svc: svc, + } + + router.Route("/api/rest/v1/example", func(sr chi.Router) { + initGetRoutes(sr, h) + initPostRoutes(sr, h) + }) +} + +func initGetRoutes(router chi.Router, h *Controller) { + router.Method( + http.MethodGet, + "/", + web.CreateHandler( + h.GetExample, + h.GetExampleRequest, + h.GetExampleResponse, + ), + ) +} + +func initPostRoutes(router chi.Router, h *Controller) { + router.Method( + http.MethodPost, + fmt.Sprintf("/{%s}", categoryParam), + web.CreateHandler( + h.SetExample, + h.SetExampleRequest, + h.SetExampleResponse, + ), + ) +} diff --git a/internal/controller/examplectl/examplectl_get.go b/internal/controller/examplectl/examplectl_get.go new file mode 100644 index 0000000..9d673c3 --- /dev/null +++ b/internal/controller/examplectl/examplectl_get.go @@ -0,0 +1,59 @@ +package examplectl + +import ( + "context" + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "net/http" + "net/url" + "strconv" +) + +const minValueParam = "min_value" + +type RequestGetExample struct { + minValue int64 +} + +func (c *Controller) GetExample(ctx context.Context, req *RequestGetExample, w http.ResponseWriter) (*apimodel.Example, error) { + val, err := c.svc.ObtainNextValue(ctx, req.minValue) + if err != nil { + web.SendErrorResponse(ctx, w, err) + return nil, err + } + + return &apimodel.Example{ + Value: val, + }, nil +} + +func (c *Controller) GetExampleRequest(r *http.Request, w http.ResponseWriter) (*RequestGetExample, error) { + minValue, err := parseIntQueryParam(r, minValueParam) + if err != nil { + web.SendErrorResponse(r.Context(), w, err) + return nil, err + } + + return &RequestGetExample{ + minValue: minValue, + }, nil +} + +func (c *Controller) GetExampleResponse(ctx context.Context, res *apimodel.Example, w http.ResponseWriter) error { + return web.EncodeWithStatus(http.StatusOK, res, w) +} + +func parseIntQueryParam(r *http.Request, name string) (int64, error) { + valueStr := r.URL.Query().Get(name) + if valueStr != "" { + val, err := strconv.Atoi(valueStr) + if err != nil { + return 0, common.NewBadRequest(r.Context(), common.RequestParseFailed, url.Values{"request": []string{fmt.Sprintf("parameter %s invalid - must be a valid integer", name)}}) + } + return int64(val), nil + } else { + return 0, nil + } +} diff --git a/internal/controller/examplectl/examplectl_post.go b/internal/controller/examplectl/examplectl_post.go new file mode 100644 index 0000000..bb325d3 --- /dev/null +++ b/internal/controller/examplectl/examplectl_post.go @@ -0,0 +1,54 @@ +package examplectl + +import ( + "context" + "encoding/json" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "github.com/go-chi/chi/v5" + "net/http" + "net/url" +) + +type RequestSetExample struct { + category string + body apimodel.Example +} + +type ResponseEmpty struct{} + +func (c *Controller) SetExample(ctx context.Context, req *RequestSetExample, w http.ResponseWriter) (*ResponseEmpty, error) { + return nil, nil +} + +func (c *Controller) SetExampleRequest(r *http.Request, w http.ResponseWriter) (*RequestSetExample, error) { + category := chi.URLParam(r, categoryParam) + + body, err := parseExampleBody(r) + if err != nil { + web.SendErrorResponse(r.Context(), w, err) + return nil, err + } + + return &RequestSetExample{ + category: category, + body: body, + }, nil +} + +func (c *Controller) SetExampleResponse(ctx context.Context, res *ResponseEmpty, w http.ResponseWriter) error { + w.WriteHeader(http.StatusNoContent) + return nil +} + +func parseExampleBody(r *http.Request) (apimodel.Example, error) { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + dto := apimodel.Example{} + err := decoder.Decode(&dto) + if err != nil { + return dto, common.NewBadRequest(r.Context(), common.RequestParseFailed, url.Values{"request": []string{"request body invalid"}}) + } + return dto, nil +} diff --git a/internal/controller/infoctl/infoctl.go b/internal/controller/infoctl/infoctl.go new file mode 100644 index 0000000..68ca709 --- /dev/null +++ b/internal/controller/infoctl/infoctl.go @@ -0,0 +1,29 @@ +package infoctl + +import ( + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "github.com/go-chi/chi/v5" + "net/http" +) + +type Controller struct{} + +func InitRoutes(router chi.Router) { + ctl := &Controller{} + + router.Route("/", func(sr chi.Router) { + initGetRoutes(sr, ctl) + }) +} + +func initGetRoutes(router chi.Router, c *Controller) { + router.Method( + http.MethodGet, + "/", + web.CreateHandler( + c.Health, + c.HealthRequest, + c.HealthResponse, + ), + ) +} diff --git a/internal/controller/infoctl/infoctl_get.go b/internal/controller/infoctl/infoctl_get.go new file mode 100644 index 0000000..af68870 --- /dev/null +++ b/internal/controller/infoctl/infoctl_get.go @@ -0,0 +1,22 @@ +package infoctl + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "github.com/eurofurence/reg-backend-template-test/internal/application/web" + "net/http" +) + +type HealthRequest struct{} + +func (c *Controller) Health(ctx context.Context, req *HealthRequest, w http.ResponseWriter) (*apimodel.Health, error) { + return &apimodel.Health{Status: "OK"}, nil +} + +func (c *Controller) HealthRequest(r *http.Request, w http.ResponseWriter) (*HealthRequest, error) { + return &HealthRequest{}, nil +} + +func (c *Controller) HealthResponse(ctx context.Context, res *apimodel.Health, w http.ResponseWriter) error { + return web.EncodeWithStatus(http.StatusOK, res, w) +} diff --git a/internal/logging/consolelogging/implementation.go b/internal/logging/consolelogging/implementation.go deleted file mode 100644 index ab5fe95..0000000 --- a/internal/logging/consolelogging/implementation.go +++ /dev/null @@ -1,48 +0,0 @@ -package consolelogging - -import ( - "github.com/eurofurence/reg-backend-template-test/internal/logging/consolelogging/logformat" - "log" - "os" -) - -const severityDEBUG = "DEBUG" -const severityINFO = "INFO" -const severityWARN = "WARN" -const severityERROR = "ERROR" -const severityFATAL = "FATAL" - -type ConsoleLoggingImpl struct { - RequestId string -} - -func (l *ConsoleLoggingImpl) isEnabled(severity string) bool { - return true -} - -func (l *ConsoleLoggingImpl) print(severity string, v ...interface{}) { - if l.isEnabled(severity) { - log.Print(logformat.Logformat(severity, l.RequestId, v...)) - } -} - -func (l *ConsoleLoggingImpl) Debug(v ...interface{}) { - l.print(severityDEBUG, v...) -} - -func (l *ConsoleLoggingImpl) Info(v ...interface{}) { - l.print(severityINFO, v...) -} - -func (l *ConsoleLoggingImpl) Warn(v ...interface{}) { - l.print(severityWARN, v...) -} - -func (l *ConsoleLoggingImpl) Error(v ...interface{}) { - l.print(severityERROR, v...) -} - -func (l *ConsoleLoggingImpl) Fatal(v ...interface{}) { - l.print(severityFATAL, v...) - os.Exit(1) -} diff --git a/internal/logging/consolelogging/implementation_test.go b/internal/logging/consolelogging/implementation_test.go deleted file mode 100644 index f8f6c3a..0000000 --- a/internal/logging/consolelogging/implementation_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package consolelogging - -import ( - "testing" -) - -var cut = &ConsoleLoggingImpl{RequestId: "00000000"} - -func TestConsoleLoggingImpl_Debug(t *testing.T) { - cut.Debug("a", "b", "c") -} - -func TestConsoleLoggingImpl_Info(t *testing.T) { - cut.Info("d", "e", "f") -} - -func TestConsoleLoggingImpl_Warn(t *testing.T) { - cut.Warn("x", "y", "z") -} - -func TestConsoleLoggingImpl_Error(t *testing.T) { - cut.Error("some error happened") -} diff --git a/internal/logging/consolelogging/logformat/logformat.go b/internal/logging/consolelogging/logformat/logformat.go deleted file mode 100644 index 141060a..0000000 --- a/internal/logging/consolelogging/logformat/logformat.go +++ /dev/null @@ -1,11 +0,0 @@ -package logformat - -import ( - "fmt" -) - -func Logformat(severity string, requestId string, v ...interface{}) string { - args := []interface{}{fmt.Sprintf("%-5s [%s] ", severity, requestId)} - args = append(args, v...) - return fmt.Sprint(args...) -} diff --git a/internal/logging/logging.go b/internal/logging/logging.go deleted file mode 100644 index 40008c5..0000000 --- a/internal/logging/logging.go +++ /dev/null @@ -1,49 +0,0 @@ -package logging - -import ( - "context" - "github.com/eurofurence/reg-backend-template-test/internal/logging/consolelogging" -) - -type Logger interface { - Debug(v ...interface{}) - Info(v ...interface{}) - Warn(v ...interface{}) - Error(v ...interface{}) - - // expected to terminate the process - Fatal(v ...interface{}) -} - -// context key with a separate type, so no other package has a chance of accessing it -type key int - -// the value actually doesn't matter, the type alone will guarantee no package gets at this context value -const loggerKey key = 0 - -var defaultLogger = createLogger("00000000") - -func createLogger(requestId string) Logger { - return &consolelogging.ConsoleLoggingImpl{RequestId: requestId} -} - -func CreateContextWithLoggerForRequestId(ctx context.Context, requestId string) context.Context { - return context.WithValue(ctx, loggerKey, createLogger(requestId)) -} - -// you should only use this when your code really does not belong to request processing. -// otherwise be a good citizen and do pass down the context, so log output can be associated with -// the request being processed! -func NoCtx() Logger { - return defaultLogger -} - -// whenever processing a specific request, use this and give it the context. -func Ctx(ctx context.Context) Logger { - logger, ok := ctx.Value(loggerKey).(Logger) - if !ok { - // better than no logger at all - return defaultLogger - } - return logger -} diff --git a/internal/repository/configuration/configuration.go b/internal/repository/configuration/configuration.go new file mode 100644 index 0000000..f879075 --- /dev/null +++ b/internal/repository/configuration/configuration.go @@ -0,0 +1,49 @@ +package configuration + +import ( + "github.com/eurofurence/reg-backend-template-test/internal/application/middleware" + "github.com/eurofurence/reg-backend-template-test/internal/application/server" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + "github.com/eurofurence/reg-backend-template-test/internal/repository/logging" + "github.com/eurofurence/reg-backend-template-test/internal/repository/vault" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" +) + +func Setup() error { + if err := auconfigenv.Setup(ConfigItems(), warn); err != nil { + return err + } + if err := auconfigenv.Read(); err != nil { + return err + } + if err := auconfigenv.Validate(); err != nil { + return err + } + return nil +} + +func ConfigItems() []auconfigapi.ConfigItem { + return join( + logging.ConfigItems(), + server.ConfigItems(), + middleware.CorsConfigItems(), + middleware.SecurityConfigItems(), + vault.ConfigItems(), + idp.ConfigItems(), + // add new config item providers here + ) +} + +func join(configs ...[]auconfigapi.ConfigItem) []auconfigapi.ConfigItem { + result := make([]auconfigapi.ConfigItem, 0) + for _, items := range configs { + result = append(result, items...) + } + return result +} + +func warn(message string) { + aulogging.Logger.NoCtx().Warn().Print(message) +} diff --git a/internal/repository/database/database.go b/internal/repository/database/database.go deleted file mode 100644 index 25ab13a..0000000 --- a/internal/repository/database/database.go +++ /dev/null @@ -1,7 +0,0 @@ -package databas - -import "database/sql" - -type MyDatabase struct { - DB sql.DB -} diff --git a/internal/repository/entities/foo.go b/internal/repository/entities/foo.go deleted file mode 100644 index a357c2e..0000000 --- a/internal/repository/entities/foo.go +++ /dev/null @@ -1,9 +0,0 @@ -package entities - -import "database/sql" - -type Foo struct { - Name string - Age uint32 - Address sql.NullString -} diff --git a/internal/repository/idp/client.go b/internal/repository/idp/client.go new file mode 100644 index 0000000..7fd403a --- /dev/null +++ b/internal/repository/idp/client.go @@ -0,0 +1,224 @@ +package idp + +import ( + "context" + "errors" + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + aurestbreakerprometheus "github.com/StephanHCB/go-autumn-restclient-circuitbreaker-prometheus" + aurestbreaker "github.com/StephanHCB/go-autumn-restclient-circuitbreaker/implementation/breaker" + aurestclientprometheus "github.com/StephanHCB/go-autumn-restclient-prometheus" + aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" + aurestcaching "github.com/StephanHCB/go-autumn-restclient/implementation/caching" + auresthttpclient "github.com/StephanHCB/go-autumn-restclient/implementation/httpclient" + aurestlogging "github.com/StephanHCB/go-autumn-restclient/implementation/requestlogging" + "github.com/go-http-utils/headers" + "net/http" + "time" +) + +type Options struct { + RequestTimeout time.Duration + + CacheEnabled bool + CacheRetentionTime time.Duration + + OIDCWellKnownURL string + + TokenIntrospectionURL string +} + +// --- instance creation --- + +func New(options Options) IdentityProviderClient { + instance := Impl{ + options: options, + } + + httpClient, err := auresthttpclient.New(0, nil, instance.requestManipulator) + if err != nil { + aulogging.Logger.NoCtx().Fatal().WithErr(err).Printf("Failed to instantiate IDP client - BAILING OUT: %s", err.Error()) + } + aurestclientprometheus.InstrumentHttpClient(httpClient) + + requestLoggingClient := aurestlogging.New(httpClient) + + circuitBreakerClient := aurestbreaker.New(requestLoggingClient, + "identity-provider-breaker", + 10, + 2*time.Minute, + 30*time.Second, + options.RequestTimeout, + ) + aurestbreakerprometheus.InstrumentCircuitBreakerClient(circuitBreakerClient) + + client := circuitBreakerClient + + if options.CacheEnabled { + cachingClient := aurestcaching.New(circuitBreakerClient, + instance.useCacheCondition, + storeResponseCondition, + cacheKeyFunction, + options.CacheRetentionTime, + 256, + ) + aurestclientprometheus.InstrumentCacheClient(cachingClient) + client = cachingClient + } + + instance.client = client + + return &instance +} + +func OptionsFromConfig() Options { + return Options{ + RequestTimeout: aToSeconds(auconfigenv.Get(ConfIDPRequestTimeoutSeconds)), + CacheEnabled: auconfigenv.Get(ConfIDPCacheEnabled) == "1", + CacheRetentionTime: aToSeconds(auconfigenv.Get(ConfIDPCacheRetentionSeconds)), + OIDCWellKnownURL: auconfigenv.Get(ConfOIDCWellKnownURL), + TokenIntrospectionURL: auconfigenv.Get(ConfTokenIntrospectionURL), + } +} + +func aToSeconds(s string) time.Duration { + secs, err := auconfigenv.AToInt(s) + if err != nil { + // config was validated so should only happen in tests, but use sensible value + secs = 10 + } + return time.Duration(secs) * time.Second +} + +type Impl struct { + client aurestclientapi.Client + options Options + + oidcUserInfoURL string + issuer string +} + +// useCacheCondition determines whether the cache should be used for a given request +// +// we cache only GET requests to the configured userinfo endpoint, and only for users who present a valid auth token +func (i *Impl) useCacheCondition(ctx context.Context, method string, url string, requestBody interface{}) bool { + return method == http.MethodGet && url == i.oidcUserInfoURL && common.GetAccessToken(ctx) != "" +} + +// storeResponseCondition determines whether to store a response in the cache +// +// we only cache responses of successful requests to the userinfo endpoint +func storeResponseCondition(ctx context.Context, method string, url string, requestBody interface{}, response *aurestclientapi.ParsedResponse) bool { + return response.Status == http.StatusOK +} + +// cacheKeyFunction determines the key to cache the response under +// +// we cannot use the default cache key function, we must cache per token +func cacheKeyFunction(ctx context.Context, method string, requestUrl string, requestBody interface{}) string { + return fmt.Sprintf("%s %s %s", common.GetAccessToken(ctx), method, requestUrl) +} + +// requestManipulator inserts Authorization when we are calling the userinfo endpoint +func (i *Impl) requestManipulator(ctx context.Context, r *http.Request) { + if r.Method == http.MethodGet { + urlStr := r.URL.String() + if urlStr != "" && (urlStr == i.oidcUserInfoURL) { + r.Header.Set(headers.Authorization, "Bearer "+common.GetAccessToken(ctx)) + } + } +} + +// --- implementation of IdentityProviderClient interface --- + +func (i *Impl) SetupFromWellKnown(ctx context.Context) error { + bodyDto := WellKnownResponse{} + response := aurestclientapi.ParsedResponse{ + Body: &bodyDto, + } + err := i.client.Perform(ctx, http.MethodGet, i.options.OIDCWellKnownURL, nil, &response) + if err != nil { + aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error requesting well known info from identity provider: error is %s", err.Error()) + return err + } + + if bodyDto.Issuer == "" || bodyDto.UserinfoEndpoint == "" { + aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error requesting well known info from identity provider: no issuer or user info endpoint set - probably not a valid .well-known endpoint") + return errors.New("failed to obtain issuer or user info endpoint from .well-known endpoint") + } + + i.issuer = bodyDto.Issuer + i.oidcUserInfoURL = bodyDto.UserinfoEndpoint + + return nil +} + +func (i *Impl) Issuer() string { + return i.issuer +} + +func (i *Impl) UserInfo(ctx context.Context) (*UserinfoResponse, int, error) { + userinfoEndpoint := i.oidcUserInfoURL + bodyDto := UserinfoResponse{} + response := aurestclientapi.ParsedResponse{ + Body: &bodyDto, + } + err := i.client.Perform(ctx, http.MethodGet, userinfoEndpoint, nil, &response) + if err != nil { + aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error requesting user info from identity provider: error from response is %s:%s, local error is %s", bodyDto.ErrorCode, bodyDto.ErrorDescription, err.Error()) + return nil, http.StatusBadGateway, err + } + if bodyDto.ErrorCode != "" || bodyDto.ErrorDescription != "" { + aulogging.Logger.Ctx(ctx).Error().Printf("received an error response from identity provider: error from response is %s:%s", bodyDto.ErrorCode, bodyDto.ErrorDescription) + } + if response.Status != http.StatusOK && response.Status != http.StatusUnauthorized && response.Status != http.StatusForbidden { + err = fmt.Errorf("unexpected http status %d, was expecting 200, 401, or 403", response.Status) + aulogging.Logger.Ctx(ctx).Error().Printf("error requesting user info from identity provider: error from response is %s:%s, local error is %s", bodyDto.ErrorCode, bodyDto.ErrorDescription, err.Error()) + return nil, response.Status, err + } + if response.Status == http.StatusOK { + if bodyDto.ErrorCode != "" || bodyDto.ErrorDescription != "" { + err = fmt.Errorf("received an error response from identity provider: error from response is %s:%s", bodyDto.ErrorCode, bodyDto.ErrorDescription) + return nil, response.Status, err + } + } + + if bodyDto.Subject != "" { + // got old response + return &bodyDto, response.Status, nil + } + + return &bodyDto, response.Status, nil +} + +func (i *Impl) TokenIntrospection(ctx context.Context) (*TokenIntrospectionResponse, int, error) { + tokenIntrospectionEndpoint := i.options.TokenIntrospectionURL + bodyDto := TokenIntrospectionResponse{} + response := aurestclientapi.ParsedResponse{ + Body: &bodyDto, + } + err := i.client.Perform(ctx, http.MethodGet, tokenIntrospectionEndpoint, nil, &response) + + if err != nil { + aulogging.Logger.Ctx(ctx).Error().WithErr(err).Printf("error requesting user info from identity provider: error from response is %s:%v, local error is %s", bodyDto.ErrorMessage, bodyDto.Errors, err.Error()) + return nil, http.StatusBadGateway, err + } + if bodyDto.ErrorMessage != "" || len(bodyDto.Errors) > 0 { + aulogging.Logger.Ctx(ctx).Error().Printf("received an error response from identity provider: error from response is %s:%v", bodyDto.ErrorMessage, bodyDto.Errors) + } + if response.Status != http.StatusOK && response.Status != http.StatusUnauthorized && response.Status != http.StatusForbidden { + err = fmt.Errorf("unexpected http status %d, was expecting 200, 401, or 403", response.Status) + aulogging.Logger.Ctx(ctx).Error().Printf("error requesting user info from identity provider: error from response is %s:%v, local error is %s", bodyDto.ErrorMessage, bodyDto.Errors, err.Error()) + return nil, response.Status, err + } + if response.Status == http.StatusOK { + if bodyDto.ErrorMessage != "" || len(bodyDto.Errors) > 0 { + err = fmt.Errorf("received an error response from identity provider: error from response is %s:%v", bodyDto.ErrorMessage, bodyDto.Errors) + return nil, response.Status, err + } + } + + return &bodyDto, response.Status, nil +} diff --git a/internal/repository/idp/interface.go b/internal/repository/idp/interface.go new file mode 100644 index 0000000..ae69977 --- /dev/null +++ b/internal/repository/idp/interface.go @@ -0,0 +1,101 @@ +package idp + +import ( + "context" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" +) + +type IdentityProviderClient interface { + // SetupFromWellKnown must be called at least once before any other methods can be used. + SetupFromWellKnown(ctx context.Context) error + + Issuer() string + + // UserInfo extracts the token from the context and performs a user info lookup + UserInfo(ctx context.Context) (*UserinfoResponse, int, error) + + // TokenIntrospection extracts the token from the context and performs a token info lookup + TokenIntrospection(ctx context.Context) (*TokenIntrospectionResponse, int, error) +} + +const ( + ConfOIDCWellKnownURL = "OIDC_WELL_KNOWN_URL" + ConfTokenIntrospectionURL = "IDP_TOKEN_INTROSPECTION_URL" + ConfIDPRequestTimeoutSeconds = "IDP_REQUEST_TIMEOUT_SECONDS" + ConfIDPCacheEnabled = "IDP_CACHE_ENABLED" + ConfIDPCacheRetentionSeconds = "IDP_CACHE_RETENTION_SECONDS" +) + +func ConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: ConfOIDCWellKnownURL, + Default: "", + Description: "URL of the OpenID Connect .well-known endpoint. If set, allows Identity Provider integration with autoconfiguration.", + Validate: auconfigenv.ObtainPatternValidator("^(|https?://.*)$"), + }, + { + Key: ConfTokenIntrospectionURL, + Default: "", + Description: "URL of the token introspection endpoint. If set, allows Identity Provider to validate scopes.", + Validate: auconfigenv.ObtainPatternValidator("^(|https?://.*)$"), + }, { + Key: ConfIDPRequestTimeoutSeconds, + Default: "10", + Description: "timeout in seconds for all requests to the identity provider.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 1800), + }, { + Key: ConfIDPCacheEnabled, + Default: "0", + Description: "cache IDP responses. Off by default. Enable by setting this to '1', but be aware of the security implications, namely that revoked tokens will still be accepted for the cache duration. Tradeoff with performance.", + Validate: auconfigenv.ObtainUintRangeValidator(0, 1), + }, { + Key: ConfIDPCacheRetentionSeconds, + Default: "5", + Description: "cache IDP responses for this many seconds. Please be aware of the security implications, namely that revoked tokens will still be accepted for this many seconds. Tradeoff with performance. Limited to 30 seconds, defaults to 5. Note that the cache is off by default, so this setting only has an effect if IDP_CACHE_ENABLED is set to 1.", + Validate: auconfigenv.ObtainUintRangeValidator(1, 30), + }, + } +} + +type UserinfoResponse struct { + // can leave out fields - we are using a tolerant reader + Audience []string `json:"aud"` + AuthTime int64 `json:"auth_time"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` // username + Groups []string `json:"groups"` + Issuer string `json:"iss"` + IssuedAt int64 `json:"iat"` + RequestedAt int64 `json:"rat"` + Subject string `json:"sub"` + + // in case of error, you get these fields instead + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type TokenIntrospectionResponse struct { + Active bool `json:"active"` + Scope string `json:"scope"` + ClientId string `json:"client_id"` + Sub string `json:"sub"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Nbf int64 `json:"nbf"` + Aud []string `json:"aud"` + Iss string `json:"iss"` + TokenType string `json:"token_type"` + TokenUse string `json:"token_use"` + + // in case of error, you get these fields instead + ErrorMessage string `json:"message"` + Errors map[string][]string `json:"errors"` +} + +type WellKnownResponse struct { + Issuer string `json:"issuer"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} diff --git a/internal/repository/logging/logging.go b/internal/repository/logging/logging.go new file mode 100644 index 0000000..645b45b --- /dev/null +++ b/internal/repository/logging/logging.go @@ -0,0 +1,135 @@ +package logging + +import ( + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/Roshick/go-autumn-slog/pkg/level" + auslog "github.com/Roshick/go-autumn-slog/pkg/logging" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + "log/slog" + "os" +) + +const ( + LogStyleJSON = "json" + LogStylePlain = "plain" +) + +// PreliminarySetup provides minimal structured logging before we have read the configuration. +// +// This solves the chicken and egg problem between configuration (which also configures logging) +// and logging, so errors in the configuration can be logged. +// +// After reading the configuration, you should call Setup() with it. +// +// In order to avoid log format differences in normal operation, reading the configuration +// should only write logs if it fails. +func PreliminarySetup() { + setupJSONWithAllDefaults() +} + +// Setup provides fully configured plaintext or structured logging. +// +// It also sets the default logger, so at this point even libraries that neither use slog nor aulogging +// will use our structured logger. +func Setup() error { + aulogging.RequestIdRetriever = common.GetRequestID + + style := auconfigenv.Get(ConfLogStyle) + + switch style { + case LogStylePlain: + lvl, err := level.ParseLogLevel(auconfigenv.Get(ConfLogLevel)) + if err != nil { + return err + } + + setupPlain(lvl) + case LogStyleJSON, "": + if err := setupJSON(); err != nil { + return err + } + default: + return fmt.Errorf("failed to parse log style %s, must be one of %s (default if blank), %s", style, LogStyleJSON, LogStylePlain) + } + + return nil +} + +const ( + ConfLogStyle = "LOG_STYLE" + ConfLogLevel = "LOG_LEVEL" + ConfLogTimeTransformer = "LOG_TIME_TRANSFORMER" + ConfLogAttributeKeyMappings = "LOG_ATTRIBUTE_KEY_MAPPINGS" +) + +const ECSMapping = `{ + "time": "@timestamp", + "level": "log.level", + "msg": "message", + "error": "error.message" +}` + +func ConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: ConfLogStyle, + Default: "json", + Description: "log style, defaults to json if not set", + Validate: auconfigapi.ConfigNeedsNoValidation, // validated by logging initialize + }, { + Key: ConfLogLevel, + Default: "INFO", + Description: "Minimum level of all logs. \n" + + "Supported values: TRACE, DEBUG, INFO, WARN, ERROR, FATAL, PANIC, SILENT", + Validate: auconfigapi.ConfigNeedsNoValidation, // validated by logging initialize + }, { + Key: ConfLogTimeTransformer, + Default: "UTC", + Description: "Type of transformation applied to each record's timestamp. Useful for testing purposes. Supported values: UTC, ZERO", + Validate: auconfigapi.ConfigNeedsNoValidation, // validated by logging initialize + }, { + Key: ConfLogAttributeKeyMappings, + Default: ECSMapping, + Description: "Mappings for attribute keys of all logs. " + + "Example: The entry [error: error.message] maps every attribute with key \"error\" to use the key \"error.message\" instead.", + Validate: auconfigapi.ConfigNeedsNoValidation, // validated by logging initialize + }, + } +} + +func obtainDefaultValue(key string) string { + for _, e := range ConfigItems() { + if e.Key == key { + return fmt.Sprintf("%v", e.Default) + } + } + return "" +} + +func setupJSONWithAllDefaults() { + config := auslog.NewConfig() + if err := config.ObtainValues(obtainDefaultValue); err != nil { + // too bad - can't do anything here, will have broken logging + } +} + +func setupPlain(lvl slog.Level) { + slog.SetLogLoggerLevel(lvl) + plainLogger := slog.Default() + aulogging.Logger = auslog.New().WithLogger(plainLogger) +} + +func setupJSON() error { + config := auslog.NewConfig() + if err := config.ObtainValues(auconfigenv.Get); err != nil { + return err + } + + structuredLogger := slog.New(slog.NewJSONHandler(os.Stdout, config.HandlerOptions())) + aulogging.Logger = auslog.New().WithLogger(structuredLogger) + slog.SetDefault(structuredLogger) + return nil +} diff --git a/internal/repository/timestamp/timestamp.go b/internal/repository/timestamp/timestamp.go new file mode 100644 index 0000000..3db7e47 --- /dev/null +++ b/internal/repository/timestamp/timestamp.go @@ -0,0 +1,17 @@ +package timestamp + +import "time" + +var nowFunc = time.Now + +func Now() time.Time { + return nowFunc() +} + +// exposed for testing + +func SetFakeNow(fakeNow time.Time) { + nowFunc = func() time.Time { + return fakeNow + } +} diff --git a/internal/repository/vault/vault.go b/internal/repository/vault/vault.go new file mode 100644 index 0000000..bf3d22f --- /dev/null +++ b/internal/repository/vault/vault.go @@ -0,0 +1,327 @@ +package vault + +import ( + "context" + "encoding/json" + "errors" + "fmt" + auconfigapi "github.com/StephanHCB/go-autumn-config-api" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aulogging "github.com/StephanHCB/go-autumn-logging" + aurestclientprometheus "github.com/StephanHCB/go-autumn-restclient-prometheus" + aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" + auresthttpclient "github.com/StephanHCB/go-autumn-restclient/implementation/httpclient" + aurestlogging "github.com/StephanHCB/go-autumn-restclient/implementation/requestlogging" + "github.com/go-http-utils/headers" + "net/http" + "os" + "strings" + "time" +) + +type Vault interface { + Setup(ctx context.Context) error + Authenticate(ctx context.Context) error + ObtainSecrets(ctx context.Context) error +} + +func New() Vault { + return &Impl{} +} + +const ( + VaultEnabled = "VAULT_ENABLED" + VaultServer = "VAULT_SERVER" + VaultAuthToken = "VAULT_AUTH_TOKEN" + VaultAuthKubernetesRole = "VAULT_AUTH_KUBERNETES_ROLE" + VaultAuthKubernetesTokenPath = "VAULT_AUTH_KUBERNETES_TOKEN_PATH" + VaultAuthKubernetesBackend = "VAULT_AUTH_KUBERNETES_BACKEND" + VaultSecretsConfig = "VAULT_SECRETS_CONFIG" +) + +func ConfigItems() []auconfigapi.ConfigItem { + return []auconfigapi.ConfigItem{ + { + Key: VaultEnabled, + Default: "0", + Description: "enable Vault/OpenBAO integration. Set to 1 to enable. Defaults to 0.", + Validate: auconfigenv.ObtainUintRangeValidator(0, 1), + }, { + Key: VaultServer, + EnvName: VaultServer, + Default: "", + Description: "fqdn of the vault server - do not add any other part of the URL", + Validate: auconfigenv.ObtainPatternValidator("^(|[a-z0-9.-]+)$"), + }, { + Key: VaultAuthToken, + Default: "", + Description: "authentication token used to fetch secrets. Use this to directly provide a token, for example during local development.", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: VaultAuthKubernetesRole, + Default: "", + Description: "role binding to use for vault kubernetes authentication.", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: VaultAuthKubernetesTokenPath, + Default: "/var/run/secrets/kubernetes.io/serviceaccount/token", + Description: "file path to the service-account token", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: VaultAuthKubernetesBackend, + Default: "", + Description: "authentication path for the kubernetes cluster", + Validate: auconfigapi.ConfigNeedsNoValidation, + }, { + Key: VaultSecretsConfig, + Default: "{}", + Description: "configuration consisting of vault paths and keys to fetch from the corresponding path. values will be written to the global configuration object.", + Validate: func(key string) error { + value := auconfigenv.Get(key) + _, err := parseSecretsConfig(value) + return err + }, + }, + } +} + +type vaultSecretsDef map[string][]vaultSecretDef + +type vaultSecretDef struct { + VaultKey string `json:"vaultKey"` + ConfigKey *string `json:"configKey,omitempty"` +} + +func parseSecretsConfig(jsonString string) (vaultSecretsDef, error) { + secretsConfig := vaultSecretsDef{} + if err := json.Unmarshal([]byte(jsonString), &secretsConfig); err != nil { + return nil, err + } + return secretsConfig, nil +} + +type Impl struct { + VaultEnabled bool + VaultProtocol string + VaultServer string + VaultAuthToken string + VaultAuthKubernetesRole string + VaultAuthKubernetesTokenPath string + VaultAuthKubernetesBackend string + VaultSecretsConfig vaultSecretsDef + + VaultClient aurestclientapi.Client +} + +func (v *Impl) Setup(ctx context.Context) error { + aulogging.Logger.Ctx(ctx).Info().Print("setting up vault") + + if v.VaultEnabled || auconfigenv.Get(VaultEnabled) == "1" { + v.VaultEnabled = true + + v.VaultProtocol = "https" + + if v.VaultServer == "" && auconfigenv.Get(VaultServer) != "" { + v.VaultServer = auconfigenv.Get(VaultServer) + } + + if v.VaultSecretsConfig == nil { + secretsConfig, err := parseSecretsConfig(auconfigenv.Get(VaultSecretsConfig)) + if err != nil { + return err + } + v.VaultSecretsConfig = secretsConfig + } + + if v.VaultAuthToken != "" || auconfigenv.Get(VaultAuthToken) != "" { + // user has directly specified a token, completely skip everything related to kubernetes auth + if v.VaultAuthToken == "" { + v.VaultAuthToken = auconfigenv.Get(VaultAuthToken) + } + } else { + // assume kubernetes auth + if v.VaultAuthKubernetesRole == "" { + v.VaultAuthKubernetesRole = auconfigenv.Get(VaultAuthKubernetesRole) + } + if v.VaultAuthKubernetesTokenPath == "" { + v.VaultAuthKubernetesTokenPath = auconfigenv.Get(VaultAuthKubernetesTokenPath) + } + if v.VaultAuthKubernetesBackend == "" { + v.VaultAuthKubernetesBackend = auconfigenv.Get(VaultAuthKubernetesBackend) + } + } + } + + client, err := auresthttpclient.New(15*time.Second, nil, v.vaultRequestHeaderManipulator()) + if err != nil { + return err + } + aurestclientprometheus.InstrumentHttpClient(client) + + logWrapper := aurestlogging.New(client) + + v.VaultClient = logWrapper + return nil +} + +func (v *Impl) vaultRequestHeaderManipulator() func(ctx context.Context, r *http.Request) { + return func(ctx context.Context, r *http.Request) { + r.Header.Set(headers.Accept, aurestclientapi.ContentTypeApplicationJson) + if v.VaultAuthToken != "" { + r.Header.Set("X-Vault-Token", v.VaultAuthToken) + } + } +} + +type K8sAuthRequest struct { + Jwt string `json:"jwt"` + Role string `json:"role"` +} + +type K8sAuthResponse struct { + Auth *K8sAuth `json:"auth"` + Errors []string `json:"errors"` +} + +type K8sAuth struct { + ClientToken string `json:"client_token"` +} + +func (v *Impl) Authenticate(ctx context.Context) error { + if v.VaultAuthToken != "" { + aulogging.Logger.Ctx(ctx).Info().Print("using passed in vault token, skipping authentication with vault") + } else if v.VaultEnabled { + aulogging.Logger.Ctx(ctx).Info().Print("authenticating with vault") + + remoteUrl := fmt.Sprintf("%s://%s/v1/auth/%s/login", v.VaultProtocol, v.VaultServer, v.VaultAuthKubernetesBackend) + + k8sToken, err := os.ReadFile(v.VaultAuthKubernetesTokenPath) + if err != nil { + return fmt.Errorf("unable to read vault token file from path %s: %s", v.VaultAuthKubernetesTokenPath, err.Error()) + } + + requestDto := &K8sAuthRequest{ + Jwt: string(k8sToken), + Role: v.VaultAuthKubernetesRole, + } + + responseDto := &K8sAuthResponse{} + response := &aurestclientapi.ParsedResponse{ + Body: responseDto, + } + + err = v.VaultClient.Perform(ctx, http.MethodPost, remoteUrl, requestDto, response) + if err != nil { + return err + } + + if response.Status != http.StatusOK { + return errors.New("did not receive http 200 from vault") + } + + if len(responseDto.Errors) > 0 { + aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("failed to authenticate with vault: %v", responseDto.Errors) + return errors.New("got an errors array from vault") + } + + if responseDto.Auth == nil || responseDto.Auth.ClientToken == "" { + return errors.New("response from vault did not include a client_token") + } + + v.VaultAuthToken = responseDto.Auth.ClientToken + } else { + aulogging.Logger.Ctx(ctx).Info().Print("vault disabled - skipping") + } + + return nil +} + +type SecretsResponse struct { + Data *SecretsResponseData `json:"data"` + Errors []string `json:"errors"` +} + +type SecretsResponseData struct { + Data map[string]string `json:"data"` +} + +func (v *Impl) ObtainSecrets(ctx context.Context) error { + if v.VaultEnabled { + for path, secretsConfig := range v.VaultSecretsConfig { + secrets, err := v.lowlevelObtainSecrets(ctx, path) + if err != nil { + return err + } + for _, secretConfig := range secretsConfig { + vaultKey := secretConfig.VaultKey + if secret, ok := secrets[vaultKey]; ok { + configKey := vaultKey + if secretConfig.ConfigKey != nil && *secretConfig.ConfigKey != "" { + configKey = *secretConfig.ConfigKey + } + if keys := strings.Split(configKey, "."); len(keys) > 1 { + secretsMap, err := appendSecretToMap(auconfigenv.Get(keys[0]), keys[1], secret) + if err != nil { + return fmt.Errorf("nested secret key %s from vault path %s is not valid", configKey, path) + } + auconfigenv.Set(keys[0], secretsMap) + } else { + auconfigenv.Set(configKey, secret) + } + } else { + return fmt.Errorf("key %s does not exist at vault path %s", vaultKey, path) + } + } + } + } + + return nil +} + +func (v *Impl) lowlevelObtainSecrets(ctx context.Context, fullSecretsPath string) (map[string]string, error) { + emptyMap := make(map[string]string) + + aulogging.Logger.Ctx(ctx).Info().Printf("querying vault for secrets, secret path %s", fullSecretsPath) + + remoteUrl := fmt.Sprintf("%s://%s/v1/system_kv/data/v1/%s", v.VaultProtocol, v.VaultServer, fullSecretsPath) + + responseDto := &SecretsResponse{} + response := &aurestclientapi.ParsedResponse{ + Body: responseDto, + } + + err := v.VaultClient.Perform(ctx, http.MethodGet, remoteUrl, nil, response) + if err != nil { + return emptyMap, err + } + + if response.Status != http.StatusOK { + return emptyMap, errors.New("did not receive http 200 from vault") + } + + if len(responseDto.Errors) > 0 { + aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("failed to obtain secrets from vault: %v", responseDto.Errors) + return emptyMap, errors.New("got an errors array from vault") + } + + if responseDto.Data == nil { + return emptyMap, errors.New("got no top level data structure from vault") + } + if responseDto.Data.Data == nil { + return emptyMap, errors.New("got no second level data structure from vault") + } + + return responseDto.Data.Data, nil +} + +func appendSecretToMap(secretMapJson string, secretKey string, secretValue string) (string, error) { + secretMap := make(map[string]string) + if secretMapJson != "" { + if err := json.Unmarshal([]byte(secretMapJson), &secretMap); err != nil { + return "{}", err + } + } + secretMap[secretKey] = secretValue + result, err := json.Marshal(secretMap) + return string(result), err +} diff --git a/internal/repository/vault/vault_test.go b/internal/repository/vault/vault_test.go new file mode 100644 index 0000000..d75606d --- /dev/null +++ b/internal/repository/vault/vault_test.go @@ -0,0 +1,157 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + auconfigenv "github.com/StephanHCB/go-autumn-config-env" + aurestclientapi "github.com/StephanHCB/go-autumn-restclient/api" + aurestmock "github.com/StephanHCB/go-autumn-restclient/implementation/mock" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +const key1 = "key1" +const key2 = "key2" +const key3 = "key3" +const mapKey = "mapKey" + +var testValues = map[string]string{ + key1: "value1", + key2: "value2", + key3: "value3", +} + +func setupTest() *Impl { + _ = auconfigenv.Setup(nil, nil) + vaultSecretsConfig := createVaultSecretsConfig() + + cut := &Impl{ + VaultEnabled: true, + VaultClient: mockVaultClientRequests(), + VaultSecretsConfig: vaultSecretsConfig, + } + + return cut +} + +func createVaultSecretsConfig() map[string][]vaultSecretDef { + simpleKey1 := key1 + mapKey2 := fmt.Sprintf("%s.%s", mapKey, key2) + mapKey3 := fmt.Sprintf("%s.%s", mapKey, key3) + + vaultSecretsConfig := map[string][]vaultSecretDef{ + "path/to/secret": { + {VaultKey: key1, ConfigKey: &simpleKey1}, + {VaultKey: key2, ConfigKey: &mapKey2}, + }, + "path/to/second/secret": { + {VaultKey: key3, ConfigKey: &mapKey3}, + }, + } + return vaultSecretsConfig +} + +func mockVaultClientRequests() aurestclientapi.Client { + client := aurestmock.New(map[string]aurestclientapi.ParsedResponse{ + "GET :///v1/system_kv/data/v1/path/to/secret ": { + Status: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: &SecretsResponse{ + Data: &SecretsResponseData{ + Data: map[string]string{ + key1: testValues[key1], + key2: testValues[key2], + }, + }, + }, + }, + "GET :///v1/system_kv/data/v1/path/to/second/secret ": { + Status: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: &SecretsResponse{ + Data: &SecretsResponseData{ + Data: map[string]string{ + key3: testValues[key3], + }, + }, + }, + }, + }, map[string]error{}) + return client +} + +func TestImpl_ObtainSecrets(t *testing.T) { + cut := setupTest() + + assert.NoError(t, cut.ObtainSecrets(context.Background())) + + secretMap := map[string]string{} + if assert.NoError(t, json.Unmarshal([]byte(auconfigenv.Get(mapKey)), &secretMap)) { + assert.Equal(t, map[string]string{ + key2: testValues[key2], + key3: testValues[key3], + }, secretMap) + } + + assert.Equal(t, testValues[key1], auconfigenv.Get(key1)) +} + +func Test_appendSecretToMap(t *testing.T) { + type args struct { + secretMapJson string + secretKey string + secretValue string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "empty key creates new map and appends secrets", + args: args{ + secretMapJson: "", + secretKey: "key1", + secretValue: "value1", + }, + want: "{\"key1\":\"value1\"}", + wantErr: false, + }, + { + name: "appends key to existing map", + args: args{ + secretMapJson: "{\"key1\":\"value1\"}", + secretKey: "key2", + secretValue: "value2", + }, + want: "{\"key1\":\"value1\",\"key2\":\"value2\"}", + wantErr: false, + }, + { + name: "throws error on invalid json input and returns empty map", + args: args{ + secretMapJson: "invalid", + secretKey: "key1", + secretValue: "value1", + }, + want: "{}", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := appendSecretToMap(tt.args.secretMapJson, tt.args.secretKey, tt.args.secretValue) + if (err != nil) != tt.wantErr { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/restapi/media/constants.go b/internal/restapi/media/constants.go deleted file mode 100644 index f165452..0000000 --- a/internal/restapi/media/constants.go +++ /dev/null @@ -1,4 +0,0 @@ -package media - -const ContentTypeApplicationJson = "application/json" -const ContentTypeTextPlain = "text/plain; charset=utf-8" diff --git a/internal/restapi/middleware/corsfilter.go b/internal/restapi/middleware/corsfilter.go deleted file mode 100644 index c786723..0000000 --- a/internal/restapi/middleware/corsfilter.go +++ /dev/null @@ -1,43 +0,0 @@ -package middleware - -import ( - "github.com/go-http-utils/headers" - - "github.com/eurofurence/reg-backend-template-test/internal/config" - "github.com/eurofurence/reg-backend-template-test/internal/logging" - - "net/http" -) - -func createCorsHeadersHandler(next http.Handler, config *config.Application) func(w http.ResponseWriter, r *http.Request) { - handlerFunc := func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Example for cors middleware - if config != nil && config.IsCorsDisabled { - logging.Ctx(ctx).Warn("sending headers to disable CORS. This configuration is not intended for production use, only for local development!") - w.Header().Set(headers.AccessControlAllowOrigin, "*") - w.Header().Set(headers.AccessControlAllowMethods, "POST, GET, OPTIONS, PUT, DELETE") - w.Header().Set(headers.AccessControlAllowHeaders, "content-type") - w.Header().Set(headers.AccessControlExposeHeaders, "Location, X-B3-TraceId") - } - - if r.Method == http.MethodOptions { - logging.Ctx(ctx).Info("received OPTIONS request. Responding with OK.") - w.WriteHeader(http.StatusOK) - return - } - - next.ServeHTTP(w, r) - } - return handlerFunc -} - -// would not need this extra layer in the absence of parameters - -func CorsHeadersMiddleware() func(http.Handler) http.Handler { - middlewareCreator := func(next http.Handler) http.Handler { - return http.HandlerFunc(createCorsHeadersHandler(next, nil)) - } - return middlewareCreator -} diff --git a/internal/restapi/middleware/logreqid.go b/internal/restapi/middleware/logreqid.go deleted file mode 100644 index e432a2b..0000000 --- a/internal/restapi/middleware/logreqid.go +++ /dev/null @@ -1,28 +0,0 @@ -package middleware - -import ( - "github.com/eurofurence/reg-backend-template-test/internal/logging" - - "net/http" -) - -func logRequestIdHandler(next http.Handler) func(w http.ResponseWriter, r *http.Request) { - handlerFunc := func(w http.ResponseWriter, r *http.Request) { - // example to log a request id - ctx := r.Context() - newCtx := logging.CreateContextWithLoggerForRequestId(ctx, GetRequestID(ctx)) - r = r.WithContext(newCtx) - - next.ServeHTTP(w, r) - } - return handlerFunc -} - -// would not need this extra layer in the absence of parameters - -func LogRequestIdMiddleware() func(http.Handler) http.Handler { - middlewareCreator := func(next http.Handler) http.Handler { - return http.HandlerFunc(logRequestIdHandler(next)) - } - return middlewareCreator -} diff --git a/internal/restapi/middleware/reqid.go b/internal/restapi/middleware/reqid.go deleted file mode 100644 index 0e37721..0000000 --- a/internal/restapi/middleware/reqid.go +++ /dev/null @@ -1,56 +0,0 @@ -package middleware - -import ( - "context" - "github.com/google/uuid" - "net/http" - "regexp" -) - -var RequestIDHeader = "X-Request-Id" - -type ctxKeyRequestID int - -const RequestIDKey ctxKeyRequestID = 0 - -var ValidRequestIdRegex = regexp.MustCompile("^[0-9a-f]{8}$") - -func createReqIdHandler(next http.Handler) func(w http.ResponseWriter, r *http.Request) { - handlerFunc := func(w http.ResponseWriter, r *http.Request) { - reqUuidStr := r.Header.Get(RequestIDHeader) - if !ValidRequestIdRegex.MatchString(reqUuidStr) { - reqUuid, err := uuid.NewRandom() - if err == nil { - reqUuidStr = reqUuid.String()[:8] - } else { - // this should not normally ever happen, but continue with this fixed requestId - reqUuidStr = "ffffffff" - } - } - ctx := r.Context() - newCtx := context.WithValue(ctx, RequestIDKey, reqUuidStr) - r = r.WithContext(newCtx) - - next.ServeHTTP(w, r) - } - return handlerFunc -} - -// would not need this extra layer in the absence of parameters - -func RequestIdMiddleware() func(http.Handler) http.Handler { - middlewareCreator := func(next http.Handler) http.Handler { - return http.HandlerFunc(createReqIdHandler(next)) - } - return middlewareCreator -} - -func GetRequestID(ctx context.Context) string { - if ctx == nil { - return "00000000" - } - if reqID, ok := ctx.Value(RequestIDKey).(string); ok { - return reqID - } - return "ffffffff" -} diff --git a/internal/restapi/v1/health/health.go b/internal/restapi/v1/health/health.go deleted file mode 100644 index a06c5c8..0000000 --- a/internal/restapi/v1/health/health.go +++ /dev/null @@ -1,36 +0,0 @@ -package v1health - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/eurofurence/reg-backend-template-test/internal/logging" - "github.com/eurofurence/reg-backend-template-test/internal/restapi/media" - "github.com/go-chi/chi/v5" - "github.com/go-http-utils/headers" -) - -func Create(server chi.Router) { - server.Get("/", healthGet) -} - -func healthGet(w http.ResponseWriter, r *http.Request) { - logging.Ctx(r.Context()).Info("health") - - dto := HealthResultDto{Status: "up"} - - w.Header().Add(headers.ContentType, media.ContentTypeApplicationJson) - w.WriteHeader(http.StatusOK) - writeJson(r.Context(), w, dto) -} - -func writeJson(ctx context.Context, w http.ResponseWriter, v interface{}) { - encoder := json.NewEncoder(w) - encoder.SetEscapeHTML(false) - err := encoder.Encode(v) - if err != nil { - logging.Ctx(ctx).Warn(fmt.Sprintf("error while encoding json response: %v", err)) - } -} diff --git a/internal/restapi/v1/health/health_models.go b/internal/restapi/v1/health/health_models.go deleted file mode 100644 index 176d3f2..0000000 --- a/internal/restapi/v1/health/health_models.go +++ /dev/null @@ -1,7 +0,0 @@ -// this file is automatically generated and not intended to be edited - -package v1health - -type HealthResultDto struct { - Status string `json:"status"` -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index ca50569..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,65 +0,0 @@ -// This is an example file for handling web service routes. -// When implementing the real server, make sure to create an instance of `http.Server`, -// provide a valid configuration and apply a base context - -package server - -import ( - "errors" - "github.com/eurofurence/reg-backend-template-test/internal/logging" - "github.com/eurofurence/reg-backend-template-test/internal/restapi/middleware" - v1health "github.com/eurofurence/reg-backend-template-test/internal/restapi/v1/health" - - "context" - "net" - "net/http" - "time" - - "github.com/go-chi/chi/v5" -) - -// quick and dirty method of handling the server. -// do not export as global variable in productive code -var srv *http.Server - -func Create() chi.Router { - server := chi.NewRouter() - - server.Use(middleware.RequestIdMiddleware()) - server.Use(middleware.LogRequestIdMiddleware()) - server.Use(middleware.CorsHeadersMiddleware()) - - v1health.Create(server) - // add your controllers here - return server -} - -func Serve(ctx context.Context, server chi.Router) { - const address = ":8080" - srv = &http.Server{ - Addr: address, - Handler: server, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 120 * time.Second, - BaseContext: func(l net.Listener) context.Context { - return ctx - }, - } - - if err := srv.ListenAndServe(); err != nil { - if errors.Is(err, http.ErrServerClosed) { - logging.NoCtx().Info("Server shut down normally") - } else { - logging.NoCtx().Fatal("ListenAndServe failed") - } - } -} - -func Shutdown(ctx context.Context) error { - if srv != nil { - return srv.Shutdown(ctx) - } - - return nil -} diff --git a/internal/service/example/example.go b/internal/service/example/example.go new file mode 100644 index 0000000..c715bc4 --- /dev/null +++ b/internal/service/example/example.go @@ -0,0 +1,43 @@ +package example + +import ( + "context" + apierrors "github.com/eurofurence/reg-backend-template-test/internal/application/common" + aulogging "github.com/StephanHCB/go-autumn-logging" + "net/url" +) + +// Example is a really dumb example for some business logic. +type Example interface { + ObtainNextValue(ctx context.Context, minValue int64) (int64, error) + ProvideStartValue(ctx context.Context, value int64) error +} + +func New() Example { + return &impl{ + value: 100, + } +} + +type impl struct { + value int64 +} + +func (i *impl) ObtainNextValue(ctx context.Context, minValue int64) (int64, error) { + aulogging.Info(ctx, "obtaining next value") + + i.value++ + if i.value < minValue { + return 0, apierrors.NewConflict(ctx, apierrors.ValueTooLow, url.Values{"minimum": []string{"the current value is too low"}}) + } + + return i.value, nil +} + +func (i *impl) ProvideStartValue(ctx context.Context, value int64) error { + if value > 100 { + return apierrors.NewBadRequest(ctx, apierrors.ValueTooHigh, url.Values{"details": []string{"the value must be less than 100"}}) + } + i.value = value + return nil +} diff --git a/test/acceptance/acc_example_test.go b/test/acceptance/acc_example_test.go new file mode 100644 index 0000000..710b67c --- /dev/null +++ b/test/acceptance/acc_example_test.go @@ -0,0 +1,42 @@ +package acceptance + +import ( + "github.com/eurofurence/reg-backend-template-test/docs" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "net/http" + "testing" +) + +// ------------------------------------------ +// acceptance tests for the example resource +// ------------------------------------------ + +func TestExample_Success(t *testing.T) { + tstSetup(t) + defer tstShutdown() + + docs.Given("given a logged in regular user") + token := tstValidUserToken(t, 101) + tstSetupIDPResponse(t, 101, nil) + + docs.When("when they request the example resource") + response := tstPerformGet("/api/rest/v1/example", token) + + docs.Then("then a valid response is sent with the next value") + tstRequireSuccessResponse(t, response, http.StatusOK, &apimodel.Example{Value: 42}) +} + +// security tests + +func TestExample_DenyUnauthorized(t *testing.T) { + tstSetup(t) + defer tstShutdown() + + docs.Given("given an anonymous user") + + docs.When("when they request the example resource") + response := tstPerformGet("/api/rest/v1/example", tstNoToken()) + + docs.Then("then the request is denied as unauthorized (401)") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} diff --git a/test/acceptance/acc_health_test.go b/test/acceptance/acc_health_test.go new file mode 100644 index 0000000..a00a785 --- /dev/null +++ b/test/acceptance/acc_health_test.go @@ -0,0 +1,25 @@ +package acceptance + +import ( + "github.com/eurofurence/reg-backend-template-test/docs" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "net/http" + "testing" +) + +// ---------------------------------------- +// acceptance tests for the health endpoint +// ---------------------------------------- + +func TestHealthEndpoint(t *testing.T) { + tstSetup(t) + defer tstShutdown() + + docs.Given("given an anonymous user") + + docs.When("when they access the health endpoint") + response := tstPerformGet("/", tstNoToken()) + + docs.Then("then the operation is successful") + tstRequireSuccessResponse(t, response, http.StatusOK, &apimodel.Health{Status: "OK"}) +} diff --git a/test/acceptance/dummy.go b/test/acceptance/dummy.go new file mode 100644 index 0000000..4fce81e --- /dev/null +++ b/test/acceptance/dummy.go @@ -0,0 +1,3 @@ +package acceptance + +// go wants a non-test source file in every package with tests, or cross-package test coverage will not work diff --git a/test/acceptance/local-config.yaml b/test/acceptance/local-config.yaml new file mode 100644 index 0000000..5cb9fc7 --- /dev/null +++ b/test/acceptance/local-config.yaml @@ -0,0 +1,4 @@ +# set required config for acceptance tests + +# FIELD: "value" +OIDC_ALLOWED_AUDIENCES: "14d9f37a-1eec-47c9-a949-5f1ebdf9c8e5" \ No newline at end of file diff --git a/test/acceptance/main_test.go b/test/acceptance/main_test.go new file mode 100644 index 0000000..fa40571 --- /dev/null +++ b/test/acceptance/main_test.go @@ -0,0 +1,15 @@ +package acceptance + +import ( + aulogging "github.com/StephanHCB/go-autumn-logging" + "os" + "testing" +) + +func TestMain(m *testing.M) { + // squelch normal log output + aulogging.SetupNoLoggerForTesting() + + code := m.Run() + os.Exit(code) +} diff --git a/test/acceptance/setup_test.go b/test/acceptance/setup_test.go new file mode 100644 index 0000000..5a6b079 --- /dev/null +++ b/test/acceptance/setup_test.go @@ -0,0 +1,59 @@ +package acceptance + +import ( + "context" + "github.com/eurofurence/reg-backend-template-test/internal/application/app" + "github.com/eurofurence/reg-backend-template-test/internal/application/server" + "github.com/eurofurence/reg-backend-template-test/internal/repository/configuration" + "github.com/eurofurence/reg-backend-template-test/test/mocks/idpmock" + "net/http/httptest" + "testing" +) + +var ts *httptest.Server +var application *app.Application + +func tstSetup(t *testing.T) { + t.Helper() + + ctx := context.TODO() + + application = app.New() + if err := configuration.Setup(); err != nil { + t.Error("failed to read acceptance test configuration") + t.FailNow() + } + + // pre-populate component mocks here + + application.IDPClient = idpmock.New() + + // now duplicating application setup (see app.Application.Run()) with required changes for test server + + if err := application.SetupRepositories(ctx); err != nil { + t.Error("failed to set up unmocked repositories") + t.FailNow() + } + + if err := application.SetupServices(ctx); err != nil { + t.Error("failed to set up services") + t.FailNow() + } + + router, err := server.Router(ctx, application.IDPClient) + if err != nil { + t.Error("failed to create router") + t.FailNow() + } + + if err := application.SetupControllers(ctx, router); err != nil { + t.Error("failed to set up controllers") + t.FailNow() + } + + ts = httptest.NewServer(router) +} + +func tstShutdown() { + ts.Close() +} diff --git a/test/acceptance/tokens_test.go b/test/acceptance/tokens_test.go new file mode 100644 index 0000000..78d7c74 --- /dev/null +++ b/test/acceptance/tokens_test.go @@ -0,0 +1,50 @@ +package acceptance + +import ( + "fmt" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + "github.com/eurofurence/reg-backend-template-test/test/mocks/idpmock" + "testing" +) + +func tstNoToken() string { + return "" +} + +func tstValidUserToken(t *testing.T, id uint) string { + t.Helper() + + return fmt.Sprintf("valid_user_token_%d", id) +} + +func tstSetupIDPResponse(t *testing.T, id uint, groups []string) { + t.Helper() + + token := tstValidUserToken(t, id) + aud := []string{"14d9f37a-1eec-47c9-a949-5f1ebdf9c8e5"} + sub := fmt.Sprintf("%d", id) + + idpmock.SetupResponse(application.IDPClient, token, idp.UserinfoResponse{ + Audience: aud, + AuthTime: 1516239022, + Email: "demouser@example.com", + EmailVerified: true, + Name: "John Doe", + Groups: groups, + Issuer: "http://identity.localhost/", + IssuedAt: 1516239022, + RequestedAt: 1516239022, + Subject: sub, + }, idp.TokenIntrospectionResponse{ + Active: true, + Scope: "groups fun", + ClientId: "1a4f", + Sub: sub, + Exp: 2075120816, + Iat: 1516239022, + Aud: aud, + Iss: "http://identity.localhost/", + TokenType: "", + TokenUse: "", + }) +} diff --git a/test/acceptance/utils_test.go b/test/acceptance/utils_test.go new file mode 100644 index 0000000..62dea09 --- /dev/null +++ b/test/acceptance/utils_test.go @@ -0,0 +1,165 @@ +package acceptance + +import ( + "encoding/json" + "github.com/eurofurence/reg-backend-template-test/internal/apimodel" + "github.com/go-http-utils/headers" + "github.com/stretchr/testify/require" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + "testing" +) + +const ContentTypeApplicationJSON = "application/json" + +type tstWebResponse struct { + status int + body string + contentType string + location string + header http.Header +} + +func tstWebResponseFromResponse(response *http.Response) tstWebResponse { + status := response.StatusCode + ct := "" + if val, ok := response.Header[headers.ContentType]; ok { + ct = val[0] + } + loc := "" + if val, ok := response.Header[headers.Location]; ok { + loc = val[0] + } + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Fatal(err) + } + err = response.Body.Close() + if err != nil { + log.Fatal(err) + } + return tstWebResponse{ + status: status, + body: string(body), + contentType: ct, + location: loc, + header: response.Header, + } +} + +func tstAddAuth(request *http.Request, token string) { + request.Header.Set(headers.Authorization, "Bearer "+token) + // TODO also support api token +} + +func tstPerformGet(relativeUrlWithLeadingSlash string, token string) tstWebResponse { + request, err := http.NewRequest(http.MethodGet, ts.URL+relativeUrlWithLeadingSlash, nil) + if err != nil { + log.Fatal(err) + } + tstAddAuth(request, token) + response, err := http.DefaultClient.Do(request) + if err != nil { + log.Fatal(err) + } + return tstWebResponseFromResponse(response) +} + +func tstPerformPut(relativeUrlWithLeadingSlash string, requestBody string, token string) tstWebResponse { + request, err := http.NewRequest(http.MethodPut, ts.URL+relativeUrlWithLeadingSlash, strings.NewReader(requestBody)) + if err != nil { + log.Fatal(err) + } + tstAddAuth(request, token) + request.Header.Set(headers.ContentType, ContentTypeApplicationJSON) + response, err := http.DefaultClient.Do(request) + if err != nil { + log.Fatal(err) + } + return tstWebResponseFromResponse(response) +} + +func tstPerformPost(relativeUrlWithLeadingSlash string, requestBody string, token string) tstWebResponse { + request, err := http.NewRequest(http.MethodPost, ts.URL+relativeUrlWithLeadingSlash, strings.NewReader(requestBody)) + if err != nil { + log.Fatal(err) + } + tstAddAuth(request, token) + request.Header.Set(headers.ContentType, ContentTypeApplicationJSON) + response, err := http.DefaultClient.Do(request) + if err != nil { + log.Fatal(err) + } + return tstWebResponseFromResponse(response) +} + +func tstPerformPostNoBody(relativeUrlWithLeadingSlash string, token string) tstWebResponse { + request, err := http.NewRequest(http.MethodPost, ts.URL+relativeUrlWithLeadingSlash, nil) + if err != nil { + log.Fatal(err) + } + tstAddAuth(request, token) + response, err := http.DefaultClient.Do(request) + if err != nil { + log.Fatal(err) + } + return tstWebResponseFromResponse(response) +} + +func tstPerformDelete(relativeUrlWithLeadingSlash string, token string) tstWebResponse { + request, err := http.NewRequest(http.MethodDelete, ts.URL+relativeUrlWithLeadingSlash, nil) + if err != nil { + log.Fatal(err) + } + tstAddAuth(request, token) + request.Header.Set(headers.ContentType, ContentTypeApplicationJSON) + response, err := http.DefaultClient.Do(request) + if err != nil { + log.Fatal(err) + } + return tstWebResponseFromResponse(response) +} + +func tstRenderJson(v interface{}) string { + representationBytes, err := json.Marshal(v) + if err != nil { + log.Fatal(err) + } + return string(representationBytes) +} + +// tip: dto := &apimodel.Example{} +func tstParseJson(body string, dto interface{}) { + err := json.Unmarshal([]byte(body), dto) + if err != nil { + log.Fatal(err) + } +} + +func p[T any](v T) *T { + return &v +} + +func tstRequireErrorResponse(t *testing.T, response tstWebResponse, expectedStatus int, expectedMessage string, expectedDetails interface{}) { + require.Equal(t, expectedStatus, response.status, "unexpected http response status") + errorDto := apimodel.Error{} + tstParseJson(response.body, &errorDto) + require.Equal(t, expectedMessage, string(errorDto.Message), "unexpected error code") + expectedDetailsStr, ok := expectedDetails.(string) + if ok && expectedDetailsStr != "" { + require.EqualValues(t, url.Values{"details": []string{expectedDetailsStr}}, errorDto.Details, "unexpected error details") + } + expectedDetailsUrlValues, ok := expectedDetails.(url.Values) + if ok { + require.EqualValues(t, expectedDetailsUrlValues, errorDto.Details, "unexpected error details") + } +} + +func tstRequireSuccessResponse(t *testing.T, response tstWebResponse, expectedStatus int, resultBodyPtr interface{}) { + require.Equal(t, expectedStatus, response.status, "unexpected http response status") + tstParseJson(response.body, resultBodyPtr) +} diff --git a/test/mocks/idpmock/idpmock.go b/test/mocks/idpmock/idpmock.go new file mode 100644 index 0000000..7cebc95 --- /dev/null +++ b/test/mocks/idpmock/idpmock.go @@ -0,0 +1,61 @@ +package idpmock + +import ( + "context" + "errors" + "github.com/eurofurence/reg-backend-template-test/internal/application/common" + "github.com/eurofurence/reg-backend-template-test/internal/repository/idp" + "net/http" +) + +func New() idp.IdentityProviderClient { + return &impl{ + userInfo: make(map[string]idp.UserinfoResponse), + tokenIntro: make(map[string]idp.TokenIntrospectionResponse), + } +} + +type impl struct { + userInfo map[string]idp.UserinfoResponse + tokenIntro map[string]idp.TokenIntrospectionResponse + issuer string +} + +func (i *impl) SetupFromWellKnown(ctx context.Context) error { + i.issuer = "mock-issuer" + return nil +} + +func (i *impl) Issuer() string { + return i.issuer +} + +func (i *impl) UserInfo(ctx context.Context) (*idp.UserinfoResponse, int, error) { + token := common.GetAccessToken(ctx) + + uInf, ok := i.userInfo[token] + if !ok { + return nil, http.StatusUnauthorized, errors.New("unknown token") + } + + return &uInf, http.StatusOK, nil +} + +func (i *impl) TokenIntrospection(ctx context.Context) (*idp.TokenIntrospectionResponse, int, error) { + token := common.GetAccessToken(ctx) + + tInt, ok := i.tokenIntro[token] + if !ok { + return nil, http.StatusUnauthorized, errors.New("unknown token") + } + + return &tInt, http.StatusOK, nil +} + +func SetupResponse(instance idp.IdentityProviderClient, token string, userInfo idp.UserinfoResponse, tokenIntro idp.TokenIntrospectionResponse) { + mock, ok := instance.(*impl) + if ok { + mock.userInfo[token] = userInfo + mock.tokenIntro[token] = tokenIntro + } +}