diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 68f104b..958014b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 /.github/ @AlphaOne1 \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 7fefb0a..f807229 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 The midgard contributors. +# SPDX-License-Identifier: MPL-2.0 + # These are supported funding model platforms github: [AlphaOne1] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3205926..12972eb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,7 @@ + + --- name: Bug report about: Create a report to help us improve @@ -14,8 +18,8 @@ A clear and concise description of what the bug is. Steps to reproduce the behavior: 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' +2. Click on '...' +3. Scroll down to '...' 4. See error **Expected behavior** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index cc96547..bdb31de 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,3 +1,7 @@ + + --- name: Feature request about: Suggest an idea for this project diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e2d3d9d..e031f0c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,7 @@ + + ### All Submissions: * [ ] Have you followed the guidelines in our Contributing document? @@ -13,7 +17,7 @@ You can erase any parts of this template not applicable to your Pull Request. 1. [ ] Did you add tests for the new features that cover all major code paths? 2. [ ] Does your submission pass tests? -3. [ ] Have you lint your code locally before submission? +3. [ ] Have you linted your code locally before submission? **What is the new behavior (if this is a feature change)?** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 06d8a27..cf9c213 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,5 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 -# -# 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: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5c6bceb..1e3af2f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,17 +1,6 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.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: @@ -82,7 +71,7 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with + # If the analyze-step fails for one of the languages you are analyzing with # "We were unable to automatically build your code", modify the matrix above # to set the build mode to "manual" for that language. Then modify this step # to build your code. diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml new file mode 100644 index 0000000..1f10cc3 --- /dev/null +++ b/.github/workflows/compliance.yml @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2025 The midgard contributors. +# SPDX-License-Identifier: MPL-2.0 + +name: Compliance Checks + +on: + push: + branches: + - master + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + REUSE: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 1 + + - name: REUSE Compliance Check + uses: fsfe/reuse-action@bb774aa972c2a89ff34781233d275075cbddf542 #v5.0.0 + + CheckSignedOffCommit: + if: > + github.event_name == 'push' && + !startsWith(github.actor, 'dependabot') && + github.event.pusher.name != 'web-flow' && + github.event.pusher.name != 'github-actions[bot]' && + github.event.pusher.name != 'github-merge-queue[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Determine pushed commits + id: range + run: | + set -euo pipefail + + # Use GitHub-provided SHAs to build the range for this push + BEFORE="${{ github.event.before }}" + AFTER="${{ github.sha }}" + + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ] + then + # New branch or force push without previous SHA + git rev-list --no-merges "$AFTER" > shas.txt + else + git rev-list --no-merges "$BEFORE".."$AFTER" > shas.txt + fi + + - name: Check for Signed-off-by + run: | + set -euo pipefail + missing="" + + while read -r sha + do + [ -n "$sha" ] || continue + msg=`git log --format=%B -n 1 "$sha"` + + if ! printf '%s' "$msg" | grep -Eqi '^[[:space:]]*Signed[- ]off[- ]by:' + then + echo "Commit $sha missing Signed-off-by" + missing="true" + fi + done < shas.txt + + if [ "$missing" = "true" ] + then + echo "DCO check failed on push" + exit 1 + fi + + echo "All pushed commits are signed" + + CheckSignedOffPullRequest: + if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'bypass-dco') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Get PR commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh --version + jq --version + + # Fetch all commits of the PR with pagination and extract SHAs + gh api -H "Accept: application/vnd.github+json" --paginate \ + repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/commits \ + | jq -r '.[].sha' > shas.txt + + - name: Check for Signed-off-by + run: | + set -euo pipefail + missing="" + + while read -r sha + do + [ -n "$sha" ] || continue + msg=`git log --format=%B -n 1 "$sha"` + + if ! printf '%s' "$msg" | grep -Eqi '^[[:space:]]*Signed[- ]off[- ]by:' + then + echo "Commit $sha missing Signed-off-by" + missing="true" + fi + done < shas.txt + + if [ "$missing" = "true" ] + then + echo "DCO check failed"; exit 1 + fi + + echo "All commits are signed" \ No newline at end of file diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6bf22c3..60f9520 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,14 +1,6 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 -# -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Request, -# surfacing known-vulnerable versions of the packages declared or updated in the PR. -# Once installed, if the workflow run is marked as required, -# PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action + name: 'Dependency Review' on: [pull_request] @@ -26,5 +18,6 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: 'Dependency Review' uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9da6f75 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2025 The midgard contributors. +# SPDX-License-Identifier: MPL-2.0 + +name: Release + +on: + release: + types: + - published + +permissions: read-all + +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: true + +jobs: + Build: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.release.tag_name }} + + - name: Generate source archive + shell: bash + run: | + set -euo pipefail + + TAG=`echo "${{ github.event.release.tag_name }}" | sed 's/\//-/g'` + git archive \ + --format=tar.gz \ + --prefix="midgard-src-${TAG}/" \ + --output="midgard-src-${TAG}.tar.gz" \ + "${{ github.event.release.tag_name }}" + + - name: Upload Release (via GitHub CLI) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + gh release upload "${{ github.event.release.tag_name }}" midgard-src-*.tar.gz --clobber + + ChecksumReleaseAssets: + needs: Build + runs-on: ubuntu-latest + name: Checksum Release Assets + outputs: + hashBase64File: ${{ steps.hashes.outputs.handle }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 1 + + - name: Download all release assets via GitHub CLI + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + mkdir -p release-assets + cd release-assets + # gets all assets of the release + gh release download "${{ github.event.release.tag_name }}" --clobber + echo "Downloaded assets:" + ls -lah + + - name: Generate Checksums + working-directory: release-assets + run: | + set -euo pipefail + + # Robustly hash all regular files in this directory, then base64 and write via tee. + LC_ALL=C find . -maxdepth 1 -type f -printf '%P\0' \ + | sort -z \ + | xargs -0 sha256sum -- \ + | base64 -w0 \ + > check.sha256 + + - name: Upload Checksums + id: hashes + uses: slsa-framework/slsa-github-generator/actions/generator/generic/create-base64-subjects-from-file@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # 2.1.0 + with: + path: release-assets/check.sha256 + + AssetProvenance: + permissions: + actions: read # Needed for detection of GitHub Actions environment. + id-token: write # Needed for provenance signing and ID + contents: write # Needed for release uploads + needs: ChecksumReleaseAssets + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 #must have semver! + with: + base64-subjects-as-file: | + ${{ needs.ChecksumReleaseAssets.outputs.hashBase64File }} + upload-assets: true + upload-tag-name: "${{ github.event.release.tag_name }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f45b2fb..afc7157 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,9 +1,5 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 -# -# This workflow uses actions that are not certified by GitHub. They are provided -# by a third-party and are governed by separate terms of service, privacy -# policy, and support documentation. name: Scorecard supply-chain security on: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4741065..a7d0f70 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,4 +1,4 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 name: Security @@ -46,7 +46,7 @@ jobs: strategy: matrix: go-version: - - "1.23" + - stable runs-on: ubuntu-latest permissions: security-events: write @@ -69,9 +69,11 @@ jobs: output-file: govulncheck-results.sarif - name: PrintSarif + id: PrintSarif run: | cat govulncheck-results.sarif - if [ grep results govulncheck-results.serif ] + + if grep "'results'" govulncheck-results.sarif then echo "hasResults=true" >> $GITHUB_OUTPUT else diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6071576..322b0ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -# Copyright the midgard contributors. +# SPDX-FileCopyrightText: 2025 The midgard contributors. # SPDX-License-Identifier: MPL-2.0 name: Tests @@ -11,7 +11,7 @@ on: branches: - master -# Declare default permissions as read only. +# Declare default permissions as read-only. permissions: read-all jobs: @@ -19,13 +19,13 @@ jobs: strategy: matrix: go-version: - - "1.23" + - "stable" runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: - egress-policy: audit + egress-policy: audit - name: Install Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 @@ -44,13 +44,13 @@ jobs: strategy: matrix: go-version: - - "1.23" + - "stable" runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: - egress-policy: audit + egress-policy: audit - name: Install Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 @@ -73,7 +73,7 @@ jobs: strategy: matrix: go-version: - - "1.23" + - "stable" platform: #- macos-latest - ubuntu-latest @@ -83,7 +83,7 @@ jobs: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: - egress-policy: audit + egress-policy: audit - name: Install Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 @@ -96,7 +96,13 @@ jobs: fetch-depth: 1 - name: Test - run: go test -v `go list ./... | grep -v example` --covermode=count --coverpkg=./... --coverprofile=coverage.txt + run: go tool gotestsum --junitfile junit.xml -- -race -v `go list ./...` --covermode=atomic --coverpkg=./... --coverprofile=coverage.txt + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} - name: Coverage run: go tool cover -func=coverage.txt @@ -111,19 +117,19 @@ jobs: strategy: matrix: go-version: - - "1.23" + - "stable" platform: #- macos-latest - ubuntu-latest #- windows-latest packages: - - ./handler/method_filter + - ./handler/methodfilter runs-on: ${{ matrix.platform }} steps: - name: Harden Runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: - egress-policy: audit + egress-policy: audit - name: Install Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 diff --git a/.gitignore b/.gitignore index 485dee6..8437b08 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ +# SPDX-FileCopyrightText: 2025 The midgard contributors. +# SPDX-License-Identifier: MPL-2.0 + .idea diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..e0ff2b7 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2025 The midgard contributors. +# SPDX-License-Identifier: MPL-2.0 + +# Configuration file for golangci-lint +# See https://golangci-lint.run/usage/configuration/ for more information + +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: all + + disable: + - exhaustruct + - forbidigo + - noinlineerr + - nonamedreturns + - wsl + - wsl_v5 + + exclusions: + warn-unused: true + + rules: + - path: _test\.go + linters: + - cyclop + - dupword + - err113 + - funlen + - gocognit + - maintidx + - nestif + + - path: examples/ + linters: + - err113 + + settings: + cyclop: + max-complexity: 20 + + depguard: + rules: + main: + files: + - $all + - "!$test" + - "!**/examples/**/*" + allow: + - $gostd + - github.com/AlphaOne1/midgard + - github.com/AlphaOne1/midgard/defs + - github.com/AlphaOne1/midgard/helper + - github.com/AlphaOne1/midgard/handler/accesslog + - github.com/AlphaOne1/midgard/handler/basicauth + - github.com/AlphaOne1/midgard/handler/correlation + - github.com/AlphaOne1/midgard/handler/cors + - github.com/AlphaOne1/midgard/handler/methodfilter + - github.com/google/uuid + - github.com/tg123/go-htpasswd + test: + files: + - $test + - "**/examples/**/*" + - "!**/examples/configSchema/main.go" + allow: + - $gostd + - github.com/AlphaOne1/midgard + - github.com/AlphaOne1/midgard/defs + - github.com/AlphaOne1/midgard/handler/accesslog + - github.com/AlphaOne1/midgard/handler/addheader + - github.com/AlphaOne1/midgard/handler/basicauth + - github.com/AlphaOne1/midgard/handler/correlation + - github.com/AlphaOne1/midgard/handler/cors + - github.com/AlphaOne1/midgard/handler/methodfilter + - github.com/AlphaOne1/midgard/handler/ratelimit + - github.com/AlphaOne1/midgard/helper + + exhaustive: + default-signifies-exhaustive: true + + mnd: + ignored-numbers: + - "2" + + perfsprint: + errorf: false + + testpackage: + skip-regexp: internal_test\.go + + varnamelen: + max-distance: 10 + ignore-decls: + - w http.ResponseWriter + - r *http.Request + + whitespace: + multi-if: true + multi-func: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/.markdownlint.json.license b/.markdownlint.json.license new file mode 100644 index 0000000..37ea0aa --- /dev/null +++ b/.markdownlint.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 The midgard contributors. +SPDX-License-Identifier: MPL-2.0 diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..5fea12d --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,8 @@ + + +Authors +======= + +- @AlphaOne1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f47f456..728de55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ + +Release 0.2.0 +============= + +- introduced golangci-lint and adapted code accordingly + - removed underscore of all package names + - renamed package util to helper + - introduced error variables for common errors +- added race check to tests +- introduced REUSE compliance check +- added release workflow with provenance generation + Release 0.1.2 ============= @@ -6,7 +20,7 @@ Release 0.1.2 Release 0.1.1 ============= -- fix add_headers to allow for duplate headers +- fix add_headers to allow for duplicate headers Release 0.1.0 ============= diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f397aad..874e3fd 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,19 +1,23 @@ + + midgard Community Code of Conduct ================================= Like the technical community as a whole, the *midgard* team and community is made up of a mixture of professionals and volunteers from all over the world, -working on every aspect of the mission - including mentorship, teaching and +working on every aspect of the mission—including mentorship, teaching and connecting people. Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to when they're participating within this community and -project. These rules apply equally to founders, mentors and those seeking help +project. These rules apply equally to founders, mentors, and those seeking help and guidance. This isn't an exhaustive list of things that you can't do. Rather, take it in -the spirit in which it's intended - a guide to make it easier to enrich all of +the spirit in which it's intended—a guide to make it easier to enrich all of us, the technical community and the conferences and user groups we hope to guide new speakers to. @@ -48,8 +52,8 @@ that we resolve disagreements and differing views constructively. Remember that we're different. The strength of *midgard* comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint -doesn't mean that they're wrong. Don't forget that it is human to err and -blaming each other doesn't get us anywhere, rather offer to help resolving +doesn't mean that they're wrong. Don't forget that it is human to make errors, +and blaming each other doesn't get us anywhere. Rather offer to help to resolve issues and to help learn from mistakes. *Original text courtesy of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c494f32..440cadb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,14 @@ + + Contributing ============ Thanks for your interest in contributing to *midgard*. Please take a moment to review this document __before submitting a pull request__. + Pull requests ------------- @@ -16,7 +21,8 @@ request that contributors create to first discuss any new ideas. Your ideas and suggestions are welcome! Please ensure that the tests are passing when submitting a pull request. If -you're adding new features to ActiveAdmin, please include tests. +you're adding new features or bugfixes to *midgard*, please include tests. + Where do I go from here? ------------------------ @@ -25,11 +31,13 @@ For any questions, support, or ideas, etc. [please create a GitHub discussion](https://github.com/AlphaOne1/midgard/discussions/new). If you've noticed a bug, [please submit an issue][new issue]. + ### Fork and create a branch If this is something you think you can fix, then [fork midgard] and create a branch with a descriptive name. + ### Get the test suite running Make sure you're using a recent Go version. @@ -37,25 +45,28 @@ Make sure you're using a recent Go version. You can run the test suite from the base folder using the following command: ```bash -go test ./... +go test -race ./... ``` + ### Implement your fix or feature At this point, you're ready to make your changes. Feel free to ask for help. -Be sure to have run the go fmt tool, to have a unified code style: +Be sure to have run the `go fmt` tool to have a unified code style: ```bash go fmt ./... ``` + ### Test your feature -After you implemented your feature, add tests that cover all major code paths. A -test coverage of 100% is not always possible. We acknowledge, that there are hard -to trigger conditions, that you might check for, but are not producible in a test -suite, but aim for the best. At least every code path of normal, input triggered -use, should be covered. +After implementing your feature, add tests that cover all major code paths. A +test coverage of 100% is not always possible. We acknowledge that there are hard +to trigger conditions that you might check for, but are not producible in a test +suite, but aim for the best. At least every code path of normal, input-triggered +use should be covered. + ### Document your feature @@ -63,33 +74,62 @@ All the good intentions go to waste, if nobody can enjoy the fruits of this labo due to non-existent (or bad, or wrong) documentation. Please take care that you include: -- a corse description of your nea feature -- generate new or update (in case) the existing examnples -- update the CHANGELOG.md +- a concise description of your new feature +- generate new or update (in case) the existing examples +- update CHANGELOG.md + +The CHANGELOG document contains the changes of the next release and all the +changes of the current major version since x.0.0. On a major release, the +CHANGELOG can be emptied as the older changes are still visible in the history +of the version control system. -The CHANGELOG document contains the changes of the next major contains all the -changes of the current major version since x.0.0. On major release, the CHANGELOG -can be emptied as the older changes are still visible in the history of the version -control system. ### Create a Pull Request At this point, if your changes look good and tests are passing, you are ready to create a pull request. -Github Actions will run the test suite against the latest Go version. There are -tests that most likey did not run in the developers machine (CodeQL, Trivy). These -tests may produce warnings. Take those warnings serious even if they seem harmless. -Too many harmless warnings could possibly overlay really serious ones, so all -warnings are to be resolved. +GitHub Actions will run the test suite against the latest Go version. There are +tests that most likely did not run on the developers machine (CodeQL, Trivy). +These tests may produce warnings. Take those warnings seriously even if they +seem harmless. Too many harmless warnings could possibly overlay really serious +ones, so all warnings are to be resolved. + Merging a PR (maintainers only) ------------------------------- -A Pull Request can only be merged into master by a maintainer if: +A maintainer can only merge a Pull Request into master if: - CI is passing, - approved by another maintainer - and is up to date with the default branch. -Any maintainer is allowed to merge a PR if all of these conditions ae met. +In addition to these automatic checks, the following conditions have to be met: + +- Changelog: The changelog has the sections with the changes of the past releases, these + are immutable less for corrections. There is at least one section explicating the next + release. All visible changes have to be included in the changelog. Further security + fixes have to be included here. + +Any maintainer is allowed to merge a PR if all of these conditions have been met. + + +Developer Certificate of Origin (DCO) +------------------------------------- + +This project enforces the [Developer Certificate of Origin (DCO)](https://developercertificate.org) +(a copy is included in [DCO.txt](DCO.txt)). +Every commit must be signed off, i.e., the commit message contains a line like: + +Signed-off-by: Your Name + +Use `git commit -s` to add this automatically (this DCO “Signed-off-by” is a +legal attestation and is different from cryptographic commit signing such as +GPG/SSH). Pull requests and pushes without proper sign-offs will fail the +compliance checks. You can alias `git commit` command to always include the +flag as follows: + +```shell +git config --global alias.ci 'commit -s' +``` diff --git a/DCO.txt b/DCO.txt new file mode 100644 index 0000000..d616011 --- /dev/null +++ b/DCO.txt @@ -0,0 +1,34 @@ +SPDX-FileCopyrightText: Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +SPDX-License-Identifier: LicenseRef-DCO + +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I have the right + to submit it under the open source license indicated in the file; or + +(b) The contribution is based upon previous work that, to the best of my + knowledge, is covered under an appropriate open source license and I have + the right under that license to submit that work with modifications, whether + created in whole or in part by me, under the same open source license + (unless I am permitted to submit under a different license), as indicated in + the file; or + +(c) The contribution was provided directly to me by some other person who + certified (a), (b) or (c) and I have not modified it. + +(d) I understand and agree that this project and the contribution are public and + that a record of the contribution (including all personal information I + submit with it, including my sign-off) is maintained indefinitely and may be + redistributed consistent with this project or the open source license(s) + involved. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..1ea8e4a --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,60 @@ + + +*midgard* Project Governance +============================= + +The *midgard* project uses a governance model commonly described as Benevolent +Dictator For Life (BDFL). This document outlines our implementation of this +model. + + +Terms +----- + +*user*: anyone who interacts with the project. + +*contributor*: anyone who has contributed to the project in any way and to any +degree. Contributions may include code, documentation, reviews, manual testing, +etc. All contributors are listed in the project's metadata +(e.g. [AUTHORS.md](AUTHORS.md)). + +*core contributor*: anyone who has contributed repeatedly and significantly to +the project. Core contributors are recognized by GitHub “Member” badges. + +*Benevolent Dictator For Life (BDFL)*: the person who makes decisions when +consensus cannot be reached. The project’s current BDFL is Alexander Adam +(@AlphaOne1). + + +Decision-Making Process +----------------------- + +Contributors try to reach consensus via discussion. When consensus cannot be +reached in a reasonable time, the BDFL takes the following steps: + + 1. Creates an issue in the project’s GitHub repository labeled governance with + a suitable title. + + 2. Waits at least 48 hours for contributors to add comments to the issue. + + 3. Adds a comment with the final decision and closes the issue. + + +Changing Governance +------------------- + +The BDFL may choose a replacement at any point and cede authority to them. If +this happens, the decision must be announced and recorded in the manner +described above for decisions in the absence of consensus. + +The new BDFL must clearly state that they accept their new responsibilities. + +The time at which the new BDFL assumes their responsibilities must be clearly +stated. + +The BDFL may alternatively decide to move to a Steering Committee governance +model, in which case this document must be replaced with a new description of +roles and responsibilities. diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 0000000..4ea99c2 --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/LicenseRef-DCO.txt b/LICENSES/LicenseRef-DCO.txt new file mode 100644 index 0000000..ad8158f --- /dev/null +++ b/LICENSES/LicenseRef-DCO.txt @@ -0,0 +1,31 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I have the right + to submit it under the open source license indicated in the file; or + +(b) The contribution is based upon previous work that, to the best of my + knowledge, is covered under an appropriate open source license and I have + the right under that license to submit that work with modifications, whether + created in whole or in part by me, under the same open source license + (unless I am permitted to submit under a different license), as indicated in + the file; or + +(c) The contribution was provided directly to me by some other person who + certified (a), (b) or (c) and I have not modified it. + +(d) I understand and agree that this project and the contribution are public and + that a record of the contribution (including all personal information I + submit with it, including my sign-off) is maintained indefinitely and may be + redistributed consistent with this project or the open source license(s) + involved. \ No newline at end of file diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index be03cd5..a7f35c3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ + + -

+

Logo
+ + REUSE compliance + @@ -80,13 +90,13 @@ Usage ----- *midgard* defines a type `Middleware` that is just a convenience to not always -having to write the full definition of what is commonly known as http middlware. +having to write the full definition of what is commonly known as http middleware. ```go type Middleware func(http.Handler) http.Handler ``` -To ease the pain of stacking different middlwares *midgard* offsers two functions +To ease the pain of stacking different middlewares, *midgard* offers two functions to facilitate it. `StackMiddlewareHandler` stacks the given slice of middlewares on top of each other and finally calls the given handler. It generates a new handler that has all the given middlewares prepended: @@ -94,15 +104,15 @@ that has all the given middlewares prepended: ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - util.Must(correlation.New()), - util.Must(access_log.New( - access_log.WithLogLevel(slog.LevelDebug))), - util.Must(cors.New( + helper.Must(correlation.New()), + helper.Must(accesslog.New( + accesslog.WithLogLevel(slog.LevelDebug))), + helper.Must(cors.New( cors.WithHeaders(cors.MinimumAllowedHeaders()), cors.WithMethods([]string{http.MethodGet}), cors.WithOrigins([]string{"*"}))), - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet}))), + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet}))), }, http.HandlerFunc(HelloHandler), ) @@ -114,31 +124,31 @@ It generates a new middleware: ```go newMiddleware:= midgard.StackMiddleware( []midgard.Middleware{ - util.Must(correlation.New()), - util.Must(access_log.New( - access_log.WithLogLevel(slog.LevelDebug))), - util.Must(cors.New( + helper.Must(correlation.New()), + helper.Must(accesslog.New( + accesslog.WithLogLevel(slog.LevelDebug))), + helper.Must(cors.New( cors.WithHeaders(cors.MinimumAllowedHeaders()), cors.WithMethods([]string{http.MethodGet}), cors.WithOrigins([]string{"*"}))), - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet}))), + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet}))), }) ``` The native solution for this would be to nest the calls to the middleware like this: ```go -finalHandler := util.Must(correlation.New())( - util.Must(access_log.New( - access_log.WithLogLevel(slog.LevelDebug)))( - util.Must(cors.New( +finalHandler := helper.Must(correlation.New())( + helper.Must(accesslog.New( + accesslog.WithLogLevel(slog.LevelDebug)))( + helper.Must(cors.New( cors.WithHeaders(cors.MinimumAllowedHeaders()), cors.WithMethods([]string{http.MethodGet}), cors.WithOrigins([]string{"*"})))( - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet})))))) + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet})))))) ``` As you see, depending on the number of middlewares, that can be quite confusing. -Further one cannot *easily* dynamially add or remove middlewares. +Further one cannot *easily* dynamically add or remove middlewares. diff --git a/SECURITY.md b/SECURITY.md index 58032a9..0ac6569 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,7 @@ + + Security Policy =============== @@ -5,7 +9,7 @@ Supported Versions ------------------ | Version | Supported | -| -------------- | ------------------ | +|----------------|--------------------| | master | :white_check_mark: | | other branches | :x: | diff --git a/defs/middleware.go b/defs/middleware.go index 50eeb99..588b5fc 100644 --- a/defs/middleware.go +++ b/defs/middleware.go @@ -1,4 +1,4 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 package defs diff --git a/defs/mwbase.go b/defs/mwbase.go index e09f4ce..3192b7d 100644 --- a/defs/mwbase.go +++ b/defs/mwbase.go @@ -1,6 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 +// Package defs contains the common types and functions for all midgard handlers. package defs import ( @@ -10,6 +11,15 @@ import ( "reflect" ) +// ErrNilLogger is returned when a logger is nil. +var ErrNilLogger = errors.New("logger cannot be nil") + +// ErrNilHandler is returned when a handler is nil. +var ErrNilHandler = errors.New("handler cannot be nil") + +// ErrNotInitialized is returned when a middleware is used before it has been initialized. +var ErrNotInitialized = errors.New("middleware not initialized") + // MWBase contains the basic middleware information common to each midgard handler. type MWBase struct { log *slog.Logger // logger @@ -35,14 +45,15 @@ func (mw *MWBase) Log() *slog.Logger { // SetLog sets a new slog.Logger to use for logging. func (mw *MWBase) SetLog(l *slog.Logger) error { if mw == nil { - return errors.New("middleware not initialized") + return ErrNotInitialized } if l == nil { - return errors.New("cannot configure with nil logger") + return ErrNilLogger } mw.log = l + return nil } @@ -59,10 +70,11 @@ func (mw *MWBase) LogLevel() slog.Level { func (mw *MWBase) SetLogLevel(l slog.Level) error { if mw != nil { mw.logLevel = l + return nil } - return errors.New("middleware not initialized") + return ErrNotInitialized } // Next gets the next handler in a chain of handlers. @@ -77,14 +89,15 @@ func (mw *MWBase) Next() http.Handler { // SetNext sets the next handler for the chain of handlers. func (mw *MWBase) SetNext(n http.Handler) error { if mw == nil { - return errors.New("middleware not initialized") + return ErrNotInitialized } if n == nil { - return errors.New("next handler must not be nil") + return ErrNilHandler } mw.next = n + return nil } @@ -93,7 +106,7 @@ func (mw *MWBase) SetNext(n http.Handler) error { func WithLogger[T MWBaser](l *slog.Logger) func(h T) error { return func(h T) error { if value := reflect.ValueOf(h); !value.IsValid() || value.IsNil() { - return errors.New("cannot configure nil handler") + return ErrNilHandler } return h.GetMWBase().SetLog(l) @@ -105,7 +118,7 @@ func WithLogger[T MWBaser](l *slog.Logger) func(h T) error { func WithLogLevel[T MWBaser](l slog.Level) func(h T) error { return func(h T) error { if value := reflect.ValueOf(h); !value.IsValid() || value.IsNil() { - return errors.New("cannot configure nil handler") + return ErrNilHandler } return h.GetMWBase().SetLogLevel(l) diff --git a/defs/mwbase_test.go b/defs/mwbase_test.go index 5e624a3..01d51f7 100644 --- a/defs/mwbase_test.go +++ b/defs/mwbase_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package defs +package defs_test import ( "fmt" @@ -9,10 +9,14 @@ import ( "net/http" "os" "testing" + + "github.com/AlphaOne1/midgard/defs" ) func TestLogNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if m.Log() != slog.Default() { t.Errorf("expected default logger on nil MWBase") @@ -20,7 +24,9 @@ func TestLogNil(t *testing.T) { } func TestSetLogNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if err := m.SetLog(slog.Default()); err == nil { t.Errorf("expected error on setting logger on nil MWBase") @@ -28,7 +34,9 @@ func TestSetLogNil(t *testing.T) { } func TestSetLogger(t *testing.T) { - var m MWBase + t.Parallel() + + var m defs.MWBase var testLogger = slog.New(slog.NewTextHandler(os.Stdout, nil)) err := (&m).SetLog(testLogger) @@ -39,7 +47,9 @@ func TestSetLogger(t *testing.T) { } func TestSetNilLogger(t *testing.T) { - var m MWBase + t.Parallel() + + var m defs.MWBase err := (&m).SetLog(nil) @@ -49,7 +59,9 @@ func TestSetNilLogger(t *testing.T) { } func TestLogLevelNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if m.LogLevel() != slog.LevelInfo { t.Errorf("expected INFO level on nil MWBase") @@ -57,7 +69,9 @@ func TestLogLevelNil(t *testing.T) { } func TestSetLogLevelNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if err := m.SetLogLevel(slog.LevelInfo); err == nil { t.Errorf("expected error on setting log level on nil MWBase") @@ -65,7 +79,9 @@ func TestSetLogLevelNil(t *testing.T) { } func TestSetLogLevel(t *testing.T) { - var m MWBase + t.Parallel() + + var m defs.MWBase err := (&m).SetLogLevel(slog.LevelWarn) @@ -75,7 +91,9 @@ func TestSetLogLevel(t *testing.T) { } func TestNextNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if m.Next() != nil { t.Errorf("expected nil next on nil MWBase") @@ -83,7 +101,9 @@ func TestNextNil(t *testing.T) { } func TestSetNextNil(t *testing.T) { - var m *MWBase + t.Parallel() + + var m *defs.MWBase if err := m.SetNext(nil); err == nil { t.Errorf("expected error on setting next on nil MWBase") @@ -91,7 +111,9 @@ func TestSetNextNil(t *testing.T) { } func TestSetNext(t *testing.T) { - var m MWBase + t.Parallel() + + var m defs.MWBase testHandler := http.Handler(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) err := (&m).SetNext(testHandler) @@ -102,7 +124,9 @@ func TestSetNext(t *testing.T) { } func TestSetNilNext(t *testing.T) { - var m MWBase + t.Parallel() + + var m defs.MWBase err := (&m).SetNext(nil) @@ -112,16 +136,18 @@ func TestSetNilNext(t *testing.T) { } type TestMWBaser struct { - MWBase + defs.MWBase } -func (h *TestMWBaser) GetMWBase() *MWBase { +func (h *TestMWBaser) GetMWBase() *defs.MWBase { return &h.MWBase } func TestWithLogger(t *testing.T) { + t.Parallel() + testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - option := WithLogger[*TestMWBaser](testLogger) + option := defs.WithLogger[*TestMWBaser](testLogger) testHandler := TestMWBaser{} err := option(&testHandler) @@ -132,9 +158,11 @@ func TestWithLogger(t *testing.T) { } func TestWithLoggerNil(t *testing.T) { + t.Parallel() + testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - option := WithLogger[MWBaser](testLogger) - var testHandler MWBaser + option := defs.WithLogger[defs.MWBaser](testLogger) + var testHandler defs.MWBaser err := option(testHandler) @@ -144,7 +172,9 @@ func TestWithLoggerNil(t *testing.T) { } func TestWithLogLevel(t *testing.T) { - option := WithLogLevel[*TestMWBaser](slog.LevelWarn) + t.Parallel() + + option := defs.WithLogLevel[*TestMWBaser](slog.LevelWarn) testHandler := TestMWBaser{} err := option(&testHandler) @@ -155,8 +185,10 @@ func TestWithLogLevel(t *testing.T) { } func TestWithLogLevelNil(t *testing.T) { - option := WithLogLevel[MWBaser](slog.LevelWarn) - var testHandler MWBaser + t.Parallel() + + option := defs.WithLogLevel[defs.MWBaser](slog.LevelWarn) + var testHandler defs.MWBaser err := option(testHandler) diff --git a/example/example_test.go b/example/example_test.go index be9ac47..d8e960a 100644 --- a/example/example_test.go +++ b/example/example_test.go @@ -1,4 +1,4 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 package main @@ -10,11 +10,12 @@ import ( "time" ) +//nolint:paralleltest // starting main with a fixed port is not safe for parallel tests func TestExampleMain(t *testing.T) { go main() time.Sleep(500 * time.Millisecond) - req, reqErr := http.NewRequest(http.MethodGet, "http://localhost:8080/", nil) + req, reqErr := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost:8080/", nil) if reqErr != nil { t.Errorf("unexpected request error: %v", reqErr) @@ -28,6 +29,8 @@ func TestExampleMain(t *testing.T) { t.Errorf("got error for hello test page: %v", resErr) } + defer func() { _ = res.Body.Close() }() + if resErr == nil && res.StatusCode != http.StatusOK { body := make([]byte, res.ContentLength) _, _ = io.ReadFull(res.Body, body) diff --git a/example/hello.html b/example/hello.html index 9e0c47f..48a3ba4 100644 --- a/example/hello.html +++ b/example/hello.html @@ -1,6 +1,5 @@ - diff --git a/example/main.go b/example/main.go index f7a0641..bbfd544 100644 --- a/example/main.go +++ b/example/main.go @@ -1,6 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 +// Package example contains an example for midgard middleware usage. package main import ( @@ -13,14 +14,13 @@ import ( "os" "time" - "github.com/AlphaOne1/midgard/handler/correlation" - "github.com/AlphaOne1/midgard" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/handler/access_log" + "github.com/AlphaOne1/midgard/handler/accesslog" + "github.com/AlphaOne1/midgard/handler/correlation" "github.com/AlphaOne1/midgard/handler/cors" - "github.com/AlphaOne1/midgard/handler/method_filter" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/methodfilter" + "github.com/AlphaOne1/midgard/helper" ) //go:embed hello.html @@ -39,15 +39,15 @@ func main() { // generate a handler that is prepended with the given middlewares finalHandler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(correlation.New()), - util.Must(access_log.New( - access_log.WithLogLevel(slog.LevelDebug))), - util.Must(cors.New( + helper.Must(correlation.New()), + helper.Must(accesslog.New( + accesslog.WithLogLevel(slog.LevelDebug))), + helper.Must(cors.New( cors.WithHeaders(append(cors.MinimumAllowHeaders(), "X-Correlation-ID")), cors.WithMethods([]string{http.MethodGet}), cors.WithOrigins([]string{"*"}))), - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet}))), + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet}))), }, http.HandlerFunc(HelloHandler), ) @@ -55,7 +55,11 @@ func main() { // register the newly generated handler for the / endpoint http.Handle("/", finalHandler) - server := &http.Server{Addr: "localhost:8080", Handler: nil} + server := &http.Server{ + Addr: "localhost:8080", + Handler: nil, + ReadHeaderTimeout: 1 * time.Second, + } go func() { time.Sleep(1 * time.Second) diff --git a/go.mod b/go.mod index 0f075dc..96abfc2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,9 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + module github.com/AlphaOne1/midgard -go 1.23.0 +go 1.24.0 require ( github.com/google/uuid v1.6.0 @@ -9,7 +12,21 @@ require ( require ( github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect - golang.org/x/crypto v0.39.0 // indirect + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/dnephin/pflag v1.0.7 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gotest.tools/gotestsum v1.13.0 // indirect ) -replace golang.org/x/crypto v0.17.0 => golang.org/x/crypto v0.35.0 +tool gotest.tools/gotestsum diff --git a/go.sum b/go.sum index 602db5e..a9d4daa 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,53 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= 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/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU= github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= +gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/go.sum.license b/go.sum.license new file mode 100644 index 0000000..37ea0aa --- /dev/null +++ b/go.sum.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 The midgard contributors. +SPDX-License-Identifier: MPL-2.0 diff --git a/handler/access_log/README.md b/handler/accesslog/README.md similarity index 70% rename from handler/access_log/README.md rename to handler/accesslog/README.md index 097855e..ece9a74 100644 --- a/handler/access_log/README.md +++ b/handler/accesslog/README.md @@ -1,3 +1,7 @@ + + Access Logging Middleware ========================= @@ -16,7 +20,7 @@ The access logging will use the default logger `slog.Default()` and INFO log lev ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - access_log.New(), + accesslog.New(), }, http.HandlerFunc(HelloHandler), ) @@ -27,9 +31,9 @@ It can also be configured with a custom logger and level. ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - access_log.New( - access_log.WithLogger(someOtherLogger), - access_log.WithLogLevel(slog.LevelDebug), + accesslog.New( + accesslog.WithLogger(someOtherLogger), + accesslog.WithLogLevel(slog.LevelDebug), ), }, http.HandlerFunc(HelloHandler), diff --git a/handler/access_log/access_log.go b/handler/accesslog/access_log.go similarity index 64% rename from handler/access_log/access_log.go rename to handler/accesslog/access_log.go index 15d191f..52d7e87 100644 --- a/handler/access_log/access_log.go +++ b/handler/accesslog/access_log.go @@ -1,24 +1,28 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package access_log +// Package accesslog provides a middleware that logs every request. +package accesslog import ( - "context" "errors" "log/slog" "net/http" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/handler/basic_auth" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/basicauth" + "github.com/AlphaOne1/midgard/helper" ) -// Handler holds the information necessary for the log +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + +// Handler holds the information necessary for the log. type Handler struct { defs.MWBase } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -28,9 +32,9 @@ func (h *Handler) GetMWBase() *defs.MWBase { } // ServeHTTP implements the access logging middleware. It logs every request with its -// correlationID, the clients address, http method and accessed path. +// correlationID, the client's address, http method and accessed path. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } @@ -48,14 +52,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if authLine := r.Header.Get("Authorization"); authLine != "" { - username, _, userFound, _ := basic_auth.ExtractUserPass(authLine) + username, _, userFound, _ := basicauth.ExtractUserPass(authLine) if userFound { entries = append(entries, slog.String("user", username)) } } - h.Log().Log(context.Background(), h.LogLevel(), "access", entries...) + h.Log().Log(r.Context(), h.LogLevel(), "access", entries...) h.Next().ServeHTTP(w, r) } @@ -72,22 +76,23 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { // New generates a new access logging middleware. func New(options ...func(*Handler) error) (defs.Middleware, error) { - h := new(Handler) + handler := new(Handler) for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(h); err != nil { + if err := opt(handler); err != nil { return nil, err } } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return h + + return handler }, nil } diff --git a/handler/access_log/access_log_test.go b/handler/accesslog/access_log_test.go similarity index 76% rename from handler/access_log/access_log_test.go rename to handler/accesslog/access_log_test.go index 8f34613..26e2187 100644 --- a/handler/access_log/access_log_test.go +++ b/handler/accesslog/access_log_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package access_log +package accesslog_test import ( "bytes" @@ -12,9 +12,11 @@ import ( "regexp" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/accesslog" + "github.com/AlphaOne1/midgard/helper" ) +//nolint:paralleltest // testing output, manipulating global log behaviour func TestAccessLogging(t *testing.T) { oldLog := slog.Default() defer slog.SetDefault(oldLog) @@ -22,9 +24,9 @@ func TestAccessLogging(t *testing.T) { logBuf := bytes.Buffer{} slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{}))) - handler := util.Must(New())(http.HandlerFunc(util.DummyHandler)) + handler := helper.Must(accesslog.New())(http.HandlerFunc(helper.DummyHandler)) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -58,6 +60,7 @@ func TestAccessLogging(t *testing.T) { } } +//nolint:paralleltest // testing output, manipulating global log behaviour func TestAccessLoggingCorrelationID(t *testing.T) { oldLog := slog.Default() defer slog.SetDefault(oldLog) @@ -65,9 +68,9 @@ func TestAccessLoggingCorrelationID(t *testing.T) { logBuf := bytes.Buffer{} slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{}))) - handler := util.Must(New())(http.HandlerFunc(util.DummyHandler)) + handler := helper.Must(accesslog.New())(http.HandlerFunc(helper.DummyHandler)) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Correlation-ID", "setOutside") rec := httptest.NewRecorder() @@ -82,6 +85,7 @@ func TestAccessLoggingCorrelationID(t *testing.T) { } } +//nolint:paralleltest // testing output, manipulating global log behaviour func TestAccessLoggingUser(t *testing.T) { oldLog := slog.Default() defer slog.SetDefault(oldLog) @@ -89,9 +93,9 @@ func TestAccessLoggingUser(t *testing.T) { logBuf := bytes.Buffer{} slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{}))) - handler := util.Must(New())(http.HandlerFunc(util.DummyHandler)) + handler := helper.Must(accesslog.New())(http.HandlerFunc(helper.DummyHandler)) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))) rec := httptest.NewRecorder() diff --git a/handler/basic_auth/base_test.go b/handler/accesslog/base_test.go similarity index 51% rename from handler/basic_auth/base_test.go rename to handler/accesslog/base_test.go index a07112a..1692242 100644 --- a/handler/basic_auth/base_test.go +++ b/handler/accesslog/base_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package basic_auth +package accesslog_test import ( "errors" @@ -11,8 +11,8 @@ import ( "os" "testing" - "github.com/AlphaOne1/midgard/handler/basic_auth/map_auth" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/accesslog" + "github.com/AlphaOne1/midgard/helper" ) // @@ -20,15 +20,18 @@ import ( // func TestHandlerNil(t *testing.T) { - var handler *Handler + t.Parallel() + + var handler *accesslog.Handler if got := handler.GetMWBase(); got != nil { t.Errorf("MWBase of nil must be nil, but got non-nil") } - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() + //goland:noinspection GoMaybeNil handler.ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusInternalServerError { @@ -41,11 +44,13 @@ func TestHandlerNil(t *testing.T) { // func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { + t.Parallel() + + errOpt := func( /* h */ *accesslog.Handler) error { return errors.New("testerror") } - _, err := New(errOpt) + _, err := accesslog.New(errOpt) if err == nil { t.Errorf("expected middleware creation to fail") @@ -53,7 +58,9 @@ func TestOptionError(t *testing.T) { } func TestOptionNil(t *testing.T) { - _, err := New(nil) + t.Parallel() + + _, err := accesslog.New(nil) if err == nil { t.Errorf("expected middleware creation to fail") @@ -61,10 +68,9 @@ func TestOptionNil(t *testing.T) { } func TestHandlerNextNil(t *testing.T) { - h := util.Must(New( - WithLogLevel(slog.LevelDebug), - WithAuthenticator(util.Must(map_auth.New(map_auth.WithAuths(map[string]string{"test": "test"}))))))( - nil) + t.Parallel() + + h := helper.Must(accesslog.New(accesslog.WithLogLevel(slog.LevelDebug)))(nil) if h != nil { t.Errorf("expected handler to be nil") @@ -76,21 +82,28 @@ func TestHandlerNextNil(t *testing.T) { // func TestOptionWithLevel(t *testing.T) { - h := util.Must(New( - WithLogLevel(slog.LevelDebug), - WithAuthenticator(util.Must(map_auth.New(map_auth.WithAuths(map[string]string{"test": "test"}))))))( - http.HandlerFunc(util.DummyHandler)) + t.Parallel() + + h := helper.Must(accesslog.New(accesslog.WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*accesslog.Handler) + + if !isValid { + t.Fatalf("wrong type") + } - if h.(*Handler).LogLevel() != slog.LevelDebug { + if val.LogLevel() != slog.LevelDebug { t.Errorf("wanted loglevel debug not set") } } func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) + t.Parallel() + + err := accesslog.WithLogLevel(slog.LevelDebug)(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } @@ -99,28 +112,37 @@ func TestOptionWithLevelOnNil(t *testing.T) { // func TestOptionWithLogger(t *testing.T) { + t.Parallel() + l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New( - WithLogger(l), - WithAuthenticator(util.Must(map_auth.New(map_auth.WithAuths(map[string]string{"test": "test"}))))))( - http.HandlerFunc(util.DummyHandler)) + h := helper.Must(accesslog.New(accesslog.WithLogger(l)))(http.HandlerFunc(helper.DummyHandler)) - if h.(*Handler).Log() != l { + val, isValid := h.(*accesslog.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != l { t.Errorf("logger not set correctly") } } func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) + t.Parallel() + + err := accesslog.WithLogger(slog.Default())(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) + t.Parallel() + + var l *slog.Logger + _, hErr := accesslog.New(accesslog.WithLogger(l)) if hErr == nil { t.Errorf("expected error on configuration with nil logger") diff --git a/handler/add_header/add_headers_test.go b/handler/add_header/add_headers_test.go deleted file mode 100644 index 6956d05..0000000 --- a/handler/add_header/add_headers_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package add_header - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/AlphaOne1/midgard/util" -) - -func TestAddHeaders(t *testing.T) { - handler := util.Must(New(WithHeaders([][2]string{ - {"X-Test-Header", "testValue"}, - })))(http.HandlerFunc(util.DummyHandler)) - - req := httptest.NewRequest("GET", "/", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - if rec.Header().Get("X-Test-Header") != "testValue" { - t.Errorf("X-Test-Header header not added correctly to request") - } -} diff --git a/handler/add_header/base_test.go b/handler/add_header/base_test.go deleted file mode 100644 index f865cc4..0000000 --- a/handler/add_header/base_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package add_header - -import ( - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/AlphaOne1/midgard/util" -) - -// -// Basic Handler -// - -func TestHandlerNil(t *testing.T) { - var handler *Handler - - if got := handler.GetMWBase(); got != nil { - t.Errorf("MWBase of nil must be nil, but got non-nil") - } - - req := httptest.NewRequest("GET", "/", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - if rec.Result().StatusCode != http.StatusInternalServerError { - t.Errorf("expected %v but got %v", http.StatusInternalServerError, rec.Result().StatusCode) - } -} - -// -// Generic Options -// - -func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { - return errors.New("testerror") - } - - _, err := New(errOpt) - - if err == nil { - t.Errorf("expected middleware creation to fail") - } -} - -func TestOptionNil(t *testing.T) { - _, err := New(nil) - - if err == nil { - t.Errorf("expected middleware creation to fail") - } -} - -func TestHandlerNextNil(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(nil) - - if h != nil { - t.Errorf("expected handler to be nil") - } -} - -// -// WithLevel -// - -func TestOptionWithLevel(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(util.DummyHandler)) - - if h.(*Handler).LogLevel() != slog.LevelDebug { - t.Errorf("wanted loglevel debug not set") - } -} - -func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) - - if err == nil { - t.Errorf("expted error on configuring nil handler") - } -} - -// -// WithLogger -// - -func TestOptionWithLogger(t *testing.T) { - l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New(WithLogger(l)))(http.HandlerFunc(util.DummyHandler)) - - if h.(*Handler).Log() != l { - t.Errorf("logger not set correctly") - } -} - -func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) - - if err == nil { - t.Errorf("expted error on configuring nil handler") - } -} - -func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) - - if hErr == nil { - t.Errorf("expected error on configuration with nil logger") - } -} diff --git a/handler/add_header/README.md b/handler/addheader/README.md similarity index 64% rename from handler/add_header/README.md rename to handler/addheader/README.md index 1c38a6f..e848137 100644 --- a/handler/add_header/README.md +++ b/handler/addheader/README.md @@ -1,8 +1,12 @@ + + Header Adding Middleware ========================= The header adding middleware is used to add custom headers to responses. Note that headers are generally not -required to be unique. So repetitions are allowed and this middleware will not check for duplicates. +required to be unique. So repetitions are allowed, and this middleware will not check for duplicates. Example ------- @@ -12,8 +16,8 @@ The header adding should be configured with actual headers to add. It will work ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - add_header.New( - add_header.WithHeaders([][2]string{ + addheader.New( + addheader.WithHeaders([][2]string{ "X-Test": "TestHeaderValue", }), ), diff --git a/handler/add_header/add_header.go b/handler/addheader/add_header.go similarity index 69% rename from handler/add_header/add_header.go rename to handler/addheader/add_header.go index 0f0c86b..91ee276 100644 --- a/handler/add_header/add_header.go +++ b/handler/addheader/add_header.go @@ -1,7 +1,8 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package add_header +// Package addheader provides a middleware for adding headers to HTTP responses. +package addheader import ( "errors" @@ -9,15 +10,20 @@ import ( "net/http" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) -// Handler holds the information of the added headers +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + +// Handler holds the information of the added headers. type Handler struct { defs.MWBase + headers [][2]string } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -28,7 +34,7 @@ func (h *Handler) GetMWBase() *defs.MWBase { // ServeHTTP handles the requests, adding the additionally provided headers to the responses. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } @@ -53,28 +59,30 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { func WithHeaders(headers [][2]string) func(*Handler) error { return func(h *Handler) error { h.headers = append(h.headers, headers...) + return nil } } // New generates a new header adding middleware. func New(options ...func(*Handler) error) (defs.Middleware, error) { - h := new(Handler) + handler := new(Handler) for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(h); err != nil { + if err := opt(handler); err != nil { return nil, err } } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return h + + return handler }, nil } diff --git a/handler/addheader/add_headers_test.go b/handler/addheader/add_headers_test.go new file mode 100644 index 0000000..cef8fa0 --- /dev/null +++ b/handler/addheader/add_headers_test.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package addheader_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/AlphaOne1/midgard/handler/addheader" + "github.com/AlphaOne1/midgard/helper" +) + +func TestAddHeaders(t *testing.T) { + t.Parallel() + + handler := helper.Must(addheader.New(addheader.WithHeaders([][2]string{ + {"X-Test-Header", "testValue"}, + })))(http.HandlerFunc(helper.DummyHandler)) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Header().Get("X-Test-Header") != "testValue" { + t.Errorf("X-Test-Header header not added correctly to response") + } +} diff --git a/handler/method_filter/base_test.go b/handler/addheader/base_test.go similarity index 51% rename from handler/method_filter/base_test.go rename to handler/addheader/base_test.go index 645b0af..794fcbe 100644 --- a/handler/method_filter/base_test.go +++ b/handler/addheader/base_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package method_filter +package addheader_test import ( "errors" @@ -11,7 +11,8 @@ import ( "os" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/addheader" + "github.com/AlphaOne1/midgard/helper" ) // @@ -19,15 +20,18 @@ import ( // func TestHandlerNil(t *testing.T) { - var handler *Handler + t.Parallel() + + var handler *addheader.Handler if got := handler.GetMWBase(); got != nil { t.Errorf("MWBase of nil must be nil, but got non-nil") } - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() + //goland:noinspection GoMaybeNil handler.ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusInternalServerError { @@ -40,11 +44,13 @@ func TestHandlerNil(t *testing.T) { // func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { + t.Parallel() + + errOpt := func( /* h */ *addheader.Handler) error { return errors.New("testerror") } - _, err := New(errOpt) + _, err := addheader.New(errOpt) if err == nil { t.Errorf("expected middleware creation to fail") @@ -52,7 +58,9 @@ func TestOptionError(t *testing.T) { } func TestOptionNil(t *testing.T) { - _, err := New(nil) + t.Parallel() + + _, err := addheader.New(nil) if err == nil { t.Errorf("expected middleware creation to fail") @@ -60,7 +68,9 @@ func TestOptionNil(t *testing.T) { } func TestHandlerNextNil(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(nil) + t.Parallel() + + h := helper.Must(addheader.New(addheader.WithLogLevel(slog.LevelDebug)))(nil) if h != nil { t.Errorf("expected handler to be nil") @@ -72,18 +82,28 @@ func TestHandlerNextNil(t *testing.T) { // func TestOptionWithLevel(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(util.DummyHandler)) + t.Parallel() + + h := helper.Must(addheader.New(addheader.WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*addheader.Handler) + + if !isValid { + t.Fatalf("wrong type") + } - if h.(*Handler).LogLevel() != slog.LevelDebug { + if val.LogLevel() != slog.LevelDebug { t.Errorf("wanted loglevel debug not set") } } func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) + t.Parallel() + + err := addheader.WithLogLevel(slog.LevelDebug)(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } @@ -92,25 +112,37 @@ func TestOptionWithLevelOnNil(t *testing.T) { // func TestOptionWithLogger(t *testing.T) { + t.Parallel() + l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New(WithLogger(l)))(http.HandlerFunc(util.DummyHandler)) + h := helper.Must(addheader.New(addheader.WithLogger(l)))(http.HandlerFunc(helper.DummyHandler)) - if h.(*Handler).Log() != l { + val, isValid := h.(*addheader.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != l { t.Errorf("logger not set correctly") } } func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) + t.Parallel() + + err := addheader.WithLogger(slog.Default())(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) + t.Parallel() + + var l *slog.Logger + _, hErr := addheader.New(addheader.WithLogger(l)) if hErr == nil { t.Errorf("expected error on configuration with nil logger") diff --git a/handler/basic_auth/map_auth/README.md b/handler/basic_auth/map_auth/README.md deleted file mode 100644 index 235417b..0000000 --- a/handler/basic_auth/map_auth/README.md +++ /dev/null @@ -1,27 +0,0 @@ -Map Authorizer -============== - -The map authorizer is a simple user-password matcher. It is configured inside -the program. It is, next to "always true", one of the most simple authorizers -possible. - -Example -------- - -```go -handler := midgard.StackMiddlewareHandler( - []defs.Middleware{ - util.Must(New( - WithAuthenticator(util.Must( - map_auth.New(map_auth.WithAuths(map[string]string{ - "user0": "pass0", - "user1": "pass1", - })))), - WithRealm("testrealm"))), - }, - http.HandlerFunc(util.DummyHandler), -) -``` - -Be aware that writing credentials inside of program code is _not_ advisable and -is just used here to illustrate the usage. diff --git a/handler/basic_auth/map_auth/map_auth_test.go b/handler/basic_auth/map_auth/map_auth_test.go deleted file mode 100644 index c8653bb..0000000 --- a/handler/basic_auth/map_auth/map_auth_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package map_auth - -import ( - "testing" -) - -func TestMapAuthenticator(t *testing.T) { - tests := []struct { - Auths map[string]string - User string - Pass string - WantNewErr bool - Want bool - WantError bool - }{ - { - Auths: map[string]string{"testuser": "testpass"}, - User: "testuser", - Pass: "testpass", - WantNewErr: false, - Want: true, - WantError: false, - }, - { - Auths: map[string]string{"testuser": "testpass"}, - User: "testuser", - Pass: "testwrong", - WantNewErr: false, - Want: false, - WantError: false, - }, - { - Auths: map[string]string{}, - User: "testuser", - Pass: "testpass", - WantNewErr: true, - Want: false, - WantError: true, - }, - } - - for k, v := range tests { - auth, newErr := New(WithAuths(v.Auths)) - - if newErr != nil { - if !v.WantNewErr { - t.Errorf("%v: got error on creation, but wanted none", k) - } - continue - } else { - if v.WantNewErr { - t.Errorf("%v: wanted error on creation, but got none", k) - continue - } - } - - gotAuth, gotErr := auth.Authenticate(v.User, v.Pass) - - if gotErr != nil { - if !v.WantError { - t.Errorf("%v: did not expect error, but got: %v", k, gotErr) - } - if gotAuth { - t.Errorf("%v: got error, so auth should not work, but got: %v", k, gotAuth) - } - } else { - if v.WantError { - t.Errorf("%v: did expect error, but got none", k) - } - if gotAuth != v.Want { - t.Errorf("%v: got auth %v but wanted %v", k, gotAuth, v.Want) - } - } - } -} diff --git a/handler/basic_auth/README.md b/handler/basicauth/README.md similarity index 66% rename from handler/basic_auth/README.md rename to handler/basicauth/README.md index be79134..703780a 100644 --- a/handler/basic_auth/README.md +++ b/handler/basicauth/README.md @@ -1,3 +1,7 @@ + + Basic Authentication Middleware =============================== @@ -7,8 +11,8 @@ this process. A detailed description can also be found [here](https://en.wikipedia.org/wiki/Basic_access_authentication). The credentials can be stored in different forms. This module has an interface -for authenticators, that provide the final verification, and concentrates on the -protocol between the client and the server. Further a simple example +for authenticators that provide the final verification, and concentrates on the +protocol between the client and the server. Further, a simple example authenticator is provided that is to be configured with the allowed credentials. Example @@ -17,11 +21,11 @@ Example ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - util.Must(basic_auth.New( - basic_auth.WithRealm("example realm"), - basic_auth.WithAuthenticator(util.Must( - map_auth.New( - map_auth.WithAuths(map[string]string{ + helper.Must(basicauth.New( + basicauth.WithRealm("example realm"), + basicauth.WithAuthenticator(helper.Must( + mapauth.New( + mapauth.WithAuths(map[string]string{ "user0": "pass0", "user1": "pass1", }), diff --git a/handler/basicauth/base_test.go b/handler/basicauth/base_test.go new file mode 100644 index 0000000..b9031d4 --- /dev/null +++ b/handler/basicauth/base_test.go @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package basicauth_test + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/AlphaOne1/midgard/handler/basicauth" + "github.com/AlphaOne1/midgard/handler/basicauth/mapauth" + "github.com/AlphaOne1/midgard/helper" +) + +// +// Basic Handler +// + +func TestHandlerNil(t *testing.T) { + t.Parallel() + + var handler *basicauth.Handler + + if got := handler.GetMWBase(); got != nil { + t.Errorf("MWBase of nil must be nil, but got non-nil") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + //goland:noinspection GoMaybeNil + handler.ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusInternalServerError { + t.Errorf("expected %v but got %v", http.StatusInternalServerError, rec.Result().StatusCode) + } +} + +// +// Generic Options +// + +func TestOptionError(t *testing.T) { + t.Parallel() + + errOpt := func( /* h */ *basicauth.Handler) error { + return errors.New("testerror") + } + + _, err := basicauth.New(errOpt) + + if err == nil { + t.Errorf("expected middleware creation to fail") + } +} + +func TestOptionNil(t *testing.T) { + t.Parallel() + + _, err := basicauth.New(nil) + + if err == nil { + t.Errorf("expected middleware creation to fail") + } +} + +func TestHandlerNextNil(t *testing.T) { + t.Parallel() + + h := helper.Must(basicauth.New( + basicauth.WithLogLevel(slog.LevelDebug), + basicauth.WithAuthenticator(helper.Must(mapauth.New(mapauth.WithAuths(map[string]string{"test": "test"}))))))( + nil) + + if h != nil { + t.Errorf("expected handler to be nil") + } +} + +// +// WithLevel +// + +func TestOptionWithLevel(t *testing.T) { + t.Parallel() + + h := helper.Must(basicauth.New( + basicauth.WithLogLevel(slog.LevelDebug), + basicauth.WithAuthenticator(helper.Must(mapauth.New(mapauth.WithAuths(map[string]string{"test": "test"}))))))( + http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*basicauth.Handler) + + if !isValid { + fmt.Printf("wrong type") + } + + if val.LogLevel() != slog.LevelDebug { + t.Errorf("wanted loglevel debug not set") + } +} + +func TestOptionWithLevelOnNil(t *testing.T) { + t.Parallel() + + err := basicauth.WithLogLevel(slog.LevelDebug)(nil) + + if err == nil { + t.Errorf("expected error on configuring nil handler") + } +} + +// +// WithLogger +// + +func TestOptionWithLogger(t *testing.T) { + t.Parallel() + + newLog := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + h := helper.Must(basicauth.New( + basicauth.WithLogger(newLog), + basicauth.WithAuthenticator(helper.Must(mapauth.New(mapauth.WithAuths(map[string]string{"test": "test"}))))))( + http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*basicauth.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != newLog { + t.Errorf("logger not set correctly") + } +} + +func TestOptionWithLoggerOnNil(t *testing.T) { + t.Parallel() + + err := basicauth.WithLogger(slog.Default())(nil) + + if err == nil { + t.Errorf("expected error on configuring nil handler") + } +} + +func TestOptionWithNilLogger(t *testing.T) { + t.Parallel() + + var l *slog.Logger + _, hErr := basicauth.New(basicauth.WithLogger(l)) + + if hErr == nil { + t.Errorf("expected error on configuration with nil logger") + } +} diff --git a/handler/basic_auth/basic_auth.go b/handler/basicauth/basic_auth.go similarity index 79% rename from handler/basic_auth/basic_auth.go rename to handler/basicauth/basic_auth.go index 289f6e2..1f04d33 100644 --- a/handler/basic_auth/basic_auth.go +++ b/handler/basicauth/basic_auth.go @@ -1,7 +1,8 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package basic_auth +// Package basicauth implements the basic auth functionality. +package basicauth import ( "bytes" @@ -13,9 +14,15 @@ import ( "strings" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + +// ErrNoAuthenticator is returned when there is no authenticator configured. +var ErrNoAuthenticator = errors.New("no authenticator configured") + // Authenticator is an interface the basic auth handler uses to check if the // given credentials match an allowed entry. type Authenticator interface { @@ -27,12 +34,14 @@ type Authenticator interface { // Handler holds the internal data of the basic authentication middleware. type Handler struct { defs.MWBase + auth Authenticator // auth holds the Authenticator used realm string // realm to report to the client authRealmInfo string // authRealmInfo holds the response header redirect string // redirect address to authenticate } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -41,21 +50,10 @@ func (h *Handler) GetMWBase() *defs.MWBase { return &h.MWBase } -// sendNoAuth sends the client that his credentials are not allowed -func (h *Handler) sendNoAuth(w http.ResponseWriter, r *http.Request) { - if len(h.redirect) > 0 { - http.Redirect(w, r, h.redirect, http.StatusFound) - } else { - w.Header().Add("WWW-Authenticate", h.authRealmInfo) - util.WriteState(w, h.Log(), http.StatusUnauthorized) - } -} - // ExtractUserPass extracts the username and the password out of the given header // value for Authorization. It signalizes if the desired information exists or en // error, when the auth string is unprocessable. func ExtractUserPass(auth string) (user, pass string, found bool, err error) { - authInfo, headerPrefixOK := strings.CutPrefix(auth, "Basic ") if !headerPrefixOK || len(authInfo) < 6 { @@ -73,6 +71,7 @@ func ExtractUserPass(auth string) (user, pass string, found bool, err error) { if len(credentials) != 2 || len(credentials[0]) == 0 || len(credentials[1]) == 0 { + return "", "", false, nil } @@ -81,7 +80,7 @@ func ExtractUserPass(auth string) (user, pass string, found bool, err error) { // ServeHTTP implements the basic auth functionality. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } @@ -96,6 +95,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !authFound { h.sendNoAuth(w, r) + return } @@ -109,12 +109,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !hasAuth { h.sendNoAuth(w, r) + return } h.Next().ServeHTTP(w, r) } +// sendNoAuth sends the client that his credentials are not allowed. +func (h *Handler) sendNoAuth(w http.ResponseWriter, r *http.Request) { + if len(h.redirect) > 0 { + http.Redirect(w, r, h.redirect, http.StatusFound) + } else { + w.Header().Add("WWW-Authenticate", h.authRealmInfo) + helper.WriteState(w, h.Log(), http.StatusUnauthorized) + } +} + // WithAuthenticator sets the Authenticator to use. func WithAuthenticator(auth Authenticator) func(h *Handler) error { return func(h *Handler) error { @@ -124,7 +135,7 @@ func WithAuthenticator(auth Authenticator) func(h *Handler) error { } } -// WithRealm sets the realm to use +// WithRealm sets the realm to use. func WithRealm(realm string) func(h *Handler) error { return func(h *Handler) error { h.realm = realm @@ -133,6 +144,7 @@ func WithRealm(realm string) func(h *Handler) error { } } +// WithRedirect sets the redirect address to use. func WithRedirect(redirect string) func(h *Handler) error { return func(h *Handler) error { h.redirect = redirect @@ -153,32 +165,33 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { // New generates a new basic authentication middleware. func New(options ...func(handler *Handler) error) (defs.Middleware, error) { - h := Handler{} + handler := Handler{} for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(&h); err != nil { + if err := opt(&handler); err != nil { return nil, err } } - if h.auth == nil { - return nil, errors.New("no authenticator configured") + if handler.auth == nil { + return nil, ErrNoAuthenticator } - if h.realm == "" { - h.realm = "Restricted" + if handler.realm == "" { + handler.realm = "Restricted" } - h.authRealmInfo = `Basic realm="` + h.realm + `", charset="UTF-8"` + handler.authRealmInfo = `Basic realm="` + handler.realm + `", charset="UTF-8"` return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return &h + + return &handler }, nil } diff --git a/handler/basic_auth/basic_auth_test.go b/handler/basicauth/basic_auth_test.go similarity index 61% rename from handler/basic_auth/basic_auth_test.go rename to handler/basicauth/basic_auth_test.go index 0a8f301..7b5f7e9 100644 --- a/handler/basic_auth/basic_auth_test.go +++ b/handler/basicauth/basic_auth_test.go @@ -1,11 +1,12 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package basic_auth +package basicauth_test import ( "encoding/base64" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -13,7 +14,8 @@ import ( "github.com/AlphaOne1/midgard" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/basicauth" + "github.com/AlphaOne1/midgard/helper" ) type AuthTest struct{} @@ -22,10 +24,13 @@ func (a *AuthTest) Authenticate(username, password string) (bool, error) { if password == "generr" { return false, errors.New("generated") } + return username == "testuser" && password == "testpass", nil } func TestBasicAuth(t *testing.T) { + t.Parallel() + tests := []struct { User string Pass string @@ -65,44 +70,49 @@ func TestBasicAuth(t *testing.T) { handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New( - WithAuthenticator(&AuthTest{}), - WithRealm("testrealm"))), + helper.Must(basicauth.New( + basicauth.WithAuthenticator(&AuthTest{}), + basicauth.WithRealm("testrealm"))), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) - for k, v := range tests { - req, _ := http.NewRequest("GET", "/", nil) - rec := httptest.NewRecorder() + for k, test := range tests { + t.Run(fmt.Sprintf("TestBasicAuth-%d", k), func(t *testing.T) { + t.Parallel() - req.Header.Add( - "Authorization", - "Basic "+ - base64.StdEncoding.EncodeToString([]byte(v.User+":"+v.Pass))) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) + req.Header.Add( + "Authorization", + "Basic "+ + base64.StdEncoding.EncodeToString([]byte(test.User+":"+test.Pass))) - if rec.Result().StatusCode != v.WantState { - t.Errorf("%v: got state %v but wanted %v", - k, - rec.Result().StatusCode, - v.WantState) - } + handler.ServeHTTP(rec, req) + + if rec.Result().StatusCode != test.WantState { + t.Errorf("got state %v but wanted %v", + rec.Result().StatusCode, + test.WantState) + } + }) } } func TestBasicAuthDecodeError(t *testing.T) { + t.Parallel() + handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New( - WithAuthenticator(&AuthTest{}), - WithRealm("testrealm"))), + helper.Must(basicauth.New( + basicauth.WithAuthenticator(&AuthTest{}), + basicauth.WithRealm("testrealm"))), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) - req, _ := http.NewRequest("GET", "/", nil) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) rec := httptest.NewRecorder() req.Header.Add( @@ -119,23 +129,27 @@ func TestBasicAuthDecodeError(t *testing.T) { } func TestBasicAuthNoAuthenticator(t *testing.T) { - _, mwErr := New( - WithRealm("testrealm")) + t.Parallel() + + _, mwErr := basicauth.New( + basicauth.WithRealm("testrealm")) if mwErr == nil || mwErr.Error() != "no authenticator configured" { - t.Errorf("uncought undefined authenticator") + t.Errorf("uncaught undefined authenticator") } } func TestBasicAuthDefaultRealm(t *testing.T) { + t.Parallel() + handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New(WithAuthenticator(&AuthTest{}))), + helper.Must(basicauth.New(basicauth.WithAuthenticator(&AuthTest{}))), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) - req, _ := http.NewRequest("GET", "/", nil) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -154,17 +168,19 @@ func TestBasicAuthDefaultRealm(t *testing.T) { } func TestBasicAuthRedirect(t *testing.T) { + t.Parallel() + handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New( - WithAuthenticator(&AuthTest{}), - WithRedirect("/login.html"), + helper.Must(basicauth.New( + basicauth.WithAuthenticator(&AuthTest{}), + basicauth.WithRedirect("/login.html"), )), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) - req, _ := http.NewRequest("GET", "/", nil) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -180,5 +196,4 @@ func TestBasicAuthRedirect(t *testing.T) { if !strings.Contains(relocHeader, `/login.html`) { t.Errorf("redirect not set correctly: %v", relocHeader) } - } diff --git a/handler/basic_auth/htpasswd_auth/README.md b/handler/basicauth/htpasswdauth/README.md similarity index 56% rename from handler/basic_auth/htpasswd_auth/README.md rename to handler/basicauth/htpasswdauth/README.md index 5ce06bf..7592f65 100644 --- a/handler/basic_auth/htpasswd_auth/README.md +++ b/handler/basicauth/htpasswdauth/README.md @@ -1,3 +1,7 @@ + + HTPassWD Authorizer ============== @@ -10,12 +14,12 @@ Example ```go handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New( - WithAuthenticator(util.Must( - htpasswd_auth.New(htpasswd_auth.WithAuthFile('./testwd')))), - WithRealm("testrealm"))), + helper.Must(basicauth.New( + basicauth.WithAuthenticator(helper.Must( + htpasswdauth.New(htpasswdauth.WithAuthFile('./testwd')))), + basicauth.WithRealm("testrealm"))), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) ``` diff --git a/handler/basic_auth/htpasswd_auth/htpasswd_auth.go b/handler/basicauth/htpasswdauth/htpasswd_auth.go similarity index 53% rename from handler/basic_auth/htpasswd_auth/htpasswd_auth.go rename to handler/basicauth/htpasswdauth/htpasswd_auth.go index ffddc39..4466d58 100644 --- a/handler/basic_auth/htpasswd_auth/htpasswd_auth.go +++ b/handler/basicauth/htpasswdauth/htpasswd_auth.go @@ -1,17 +1,28 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package htpasswd_auth +// Package htpasswdauth implements the basic auth functionality using a htpasswd file. +package htpasswdauth import ( "errors" + "fmt" "io" "os" + "path/filepath" "github.com/tg123/go-htpasswd" + + "github.com/AlphaOne1/midgard/helper" ) -// HTPassWDAuth holds the htpasswd relevant data +// ErrEmptyInput is returned when the input is empty. +var ErrEmptyInput = errors.New("input is empty") + +// ErrNotInitialized is returned when the htpasswd authenticator is not initialized. +var ErrNotInitialized = errors.New("htpasswd auth not initialized") + +// HTPassWDAuth holds the htpasswd relevant data. type HTPassWDAuth struct { auth *htpasswd.File } @@ -20,11 +31,11 @@ type HTPassWDAuth struct { // one stored in the used htpasswd file. func (a *HTPassWDAuth) Authorize(username, password string) (bool, error) { if a == nil { - return false, errors.New("htpasswd auth not initialized") + return false, ErrNotInitialized } if a.auth == nil { - return false, errors.New("htpasswd not initialized") + return false, ErrNotInitialized } return a.auth.Match(username, password), nil @@ -35,49 +46,50 @@ func (a *HTPassWDAuth) Authorize(username, password string) (bool, error) { func WithAuthInput(in io.Reader) func(a *HTPassWDAuth) error { return func(a *HTPassWDAuth) error { if in == nil { - return errors.New("input is nil") + return ErrEmptyInput } var err error a.auth, err = htpasswd.NewFromReader(in, htpasswd.DefaultSystems, nil) - return err + + return helper.WrapIfError("could not read htpasswd input", err) } } // WithAuthFile configures the htpasswd file to be read from the // filesystem with the given name. func WithAuthFile(fileName string) func(a *HTPassWDAuth) error { - return func(a *HTPassWDAuth) error { + return func(auth *HTPassWDAuth) error { if len(fileName) == 0 { - return errors.New("input file name is necessary") + return ErrEmptyInput } - input, err := os.Open(fileName) + input, err := os.Open(filepath.Clean(fileName)) if err != nil { - return err + return fmt.Errorf("could not open auth file: %w", err) } defer func() { _ = input.Close() }() - return WithAuthInput(input)(a) + return WithAuthInput(input)(auth) } } // New creates a new htpasswd authenticator. func New(options ...func(*HTPassWDAuth) error) (*HTPassWDAuth, error) { - a := HTPassWDAuth{} + auth := HTPassWDAuth{} for _, opt := range options { - if err := opt(&a); err != nil { + if err := opt(&auth); err != nil { return nil, err } } - if a.auth == nil { - return nil, errors.New("htpasswd input is required") + if auth.auth == nil { + return nil, ErrEmptyInput } - return &a, nil + return &auth, nil } diff --git a/handler/basic_auth/htpasswd_auth/htpasswd_auth_test.go b/handler/basicauth/htpasswdauth/htpasswd_auth_test.go similarity index 66% rename from handler/basic_auth/htpasswd_auth/htpasswd_auth_test.go rename to handler/basicauth/htpasswdauth/htpasswd_auth_test.go index e13d606..4e6a6d1 100644 --- a/handler/basic_auth/htpasswd_auth/htpasswd_auth_test.go +++ b/handler/basicauth/htpasswdauth/htpasswd_auth_test.go @@ -1,15 +1,20 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package htpasswd_auth +package htpasswdauth_test import ( + "os" + "path/filepath" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/basicauth/htpasswdauth" + "github.com/AlphaOne1/midgard/helper" ) func TestHtpasswdAuth(t *testing.T) { + t.Parallel() + tests := []struct { Username string Password string @@ -32,7 +37,10 @@ func TestHtpasswdAuth(t *testing.T) { }, } - a := util.Must(New(WithAuthFile("testwd"))) + a := helper.Must( + htpasswdauth.New( + htpasswdauth.WithAuthFile( + filepath.Join(helper.Must(os.Getwd()), "/testwd")))) for k, v := range tests { gotAuth, gotErr := a.Authorize(v.Username, v.Password) @@ -48,7 +56,9 @@ func TestHtpasswdAuth(t *testing.T) { } func TestHtpasswdNil(t *testing.T) { - var subject *HTPassWDAuth = nil + t.Parallel() + + var subject *htpasswdauth.HTPassWDAuth if _, err := subject.Authorize("u", "p"); err == nil { t.Errorf("authorize on nil authorizer should give error") @@ -56,7 +66,9 @@ func TestHtpasswdNil(t *testing.T) { } func TestHtpasswdNonExistingFile(t *testing.T) { - _, err := New(WithAuthFile("IDoNotExistNowhereInThisWorldForgetIt")) + t.Parallel() + + _, err := htpasswdauth.New(htpasswdauth.WithAuthFile("IDoNotExistNowhereInThisWorldForgetIt")) if err == nil { t.Errorf("authorizer initialization with non-existent file should give error") @@ -64,7 +76,9 @@ func TestHtpasswdNonExistingFile(t *testing.T) { } func TestHtpasswdNoOptions(t *testing.T) { - _, err := New() + t.Parallel() + + _, err := htpasswdauth.New() if err == nil { t.Errorf("authorizer initialization without options should give error") @@ -72,7 +86,9 @@ func TestHtpasswdNoOptions(t *testing.T) { } func TestHtpasswdWrongReader(t *testing.T) { - _, err := New(WithAuthInput(nil)) + t.Parallel() + + _, err := htpasswdauth.New(htpasswdauth.WithAuthInput(nil)) if err == nil { t.Errorf("authorizer initialization nil reader should give error") @@ -80,7 +96,9 @@ func TestHtpasswdWrongReader(t *testing.T) { } func TestHtpasswdEmptyFilename(t *testing.T) { - _, err := New(WithAuthFile("")) + t.Parallel() + + _, err := htpasswdauth.New(htpasswdauth.WithAuthFile("")) if err == nil { t.Errorf("authorizer initialization with empty filename should give error") diff --git a/handler/basic_auth/htpasswd_auth/testwd b/handler/basicauth/htpasswdauth/testwd similarity index 100% rename from handler/basic_auth/htpasswd_auth/testwd rename to handler/basicauth/htpasswdauth/testwd diff --git a/handler/basicauth/htpasswdauth/testwd.license b/handler/basicauth/htpasswdauth/testwd.license new file mode 100644 index 0000000..37ea0aa --- /dev/null +++ b/handler/basicauth/htpasswdauth/testwd.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 The midgard contributors. +SPDX-License-Identifier: MPL-2.0 diff --git a/handler/basicauth/mapauth/README.md b/handler/basicauth/mapauth/README.md new file mode 100644 index 0000000..b01736f --- /dev/null +++ b/handler/basicauth/mapauth/README.md @@ -0,0 +1,31 @@ + + +Map Authorizer +============== + +The map authorizer is a simple user-password matcher. It is configured inside +the program. It is, next to "always true", one of the most simple authorizers +possible. + +Example +------- + +```go +handler := midgard.StackMiddlewareHandler( + []defs.Middleware{ + helper.Must(basicauth.New( + basicauth.WithAuthenticator(helper.Must( + mapauth.New(mapauth.WithAuths(map[string]string{ + "user0": "pass0", + "user1": "pass1", + })))), + basicauth.WithRealm("testrealm"))), + }, + http.HandlerFunc(helper.DummyHandler), +) +``` + +Be aware that writing credentials inside program code is _not_ advisable and is +just used here to illustrate the usage. diff --git a/handler/basic_auth/map_auth/map_auth.go b/handler/basicauth/mapauth/map_auth.go similarity index 53% rename from handler/basic_auth/map_auth/map_auth.go rename to handler/basicauth/mapauth/map_auth.go index 365326a..629f831 100644 --- a/handler/basic_auth/map_auth/map_auth.go +++ b/handler/basicauth/mapauth/map_auth.go @@ -1,11 +1,18 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package map_auth +// Package mapauth implements the basic auth functionality using a user-pass-map. +package mapauth import "errors" -// MapAuthenticator holds the authentication relevant data +// ErrNoAuthorizations is returned when no authorizations are configured. +var ErrNoAuthorizations = errors.New("no authorizations configured") + +// ErrNotInitialized is returned when the map authenticator is not initialized. +var ErrNotInitialized = errors.New("mapauth not initialized") + +// MapAuthenticator holds the authentication relevant data. type MapAuthenticator struct { auths map[string]string // map containing username-password pairs } @@ -13,13 +20,14 @@ type MapAuthenticator struct { // Authenticate checks if a given username has the given password entry // identical in the internal auths map. func (a *MapAuthenticator) Authenticate(username, password string) (bool, error) { - if a == nil { - return false, errors.New("map auth not initialized") + return false, ErrNotInitialized } + // This state should never happen, as New will not allow + // returning a MapAuthenticator without authorizations. if len(a.auths) == 0 { - return false, errors.New("no auths configured") + return false, ErrNoAuthorizations } pass, passFound := a.auths[username] @@ -31,7 +39,7 @@ func (a *MapAuthenticator) Authenticate(username, password string) (bool, error) func WithAuths(auths map[string]string) func(a *MapAuthenticator) error { return func(a *MapAuthenticator) error { if len(auths) == 0 { - return errors.New("no authorizations configured") + return ErrNoAuthorizations } if len(a.auths) == 0 { @@ -48,17 +56,17 @@ func WithAuths(auths map[string]string) func(a *MapAuthenticator) error { // New creates a new MapAuthenticator with the given configuration. func New(options ...func(a *MapAuthenticator) error) (*MapAuthenticator, error) { - a := MapAuthenticator{} + authenticator := MapAuthenticator{} for _, opt := range options { - if err := opt(&a); err != nil { + if err := opt(&authenticator); err != nil { return nil, err } } - if len(a.auths) == 0 { - return nil, errors.New("no auths configured") + if len(authenticator.auths) == 0 { + return nil, ErrNoAuthorizations } - return &a, nil + return &authenticator, nil } diff --git a/handler/basicauth/mapauth/map_auth_test.go b/handler/basicauth/mapauth/map_auth_test.go new file mode 100644 index 0000000..88057bd --- /dev/null +++ b/handler/basicauth/mapauth/map_auth_test.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package mapauth_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/AlphaOne1/midgard/handler/basicauth/mapauth" +) + +func TestMapAuthenticator(t *testing.T) { + t.Parallel() + + tests := []struct { + Auths map[string]string + User string + Pass string + WantNewErr bool + Want bool + WantError bool + }{ + { + Auths: map[string]string{"testuser": "testpass"}, + User: "testuser", + Pass: "testpass", + WantNewErr: false, + Want: true, + WantError: false, + }, + { + Auths: map[string]string{"testuser": "testpass"}, + User: "testuser", + Pass: "testwrong", + WantNewErr: false, + Want: false, + WantError: false, + }, + { + Auths: map[string]string{}, + User: "testuser", + Pass: "testpass", + WantNewErr: true, + Want: false, + WantError: true, + }, + } + + for k, test := range tests { + t.Run(fmt.Sprintf("TestMapAuthenticator-%d", k), func(t *testing.T) { + t.Parallel() + + auth, newErr := mapauth.New(mapauth.WithAuths(test.Auths)) + + if newErr != nil { + if !test.WantNewErr { + t.Errorf("got error on creation, but wanted none") + } + + return + } + + if test.WantNewErr { + t.Errorf("wanted error on creation, but got none") + + return + } + + gotAuth, gotErr := auth.Authenticate(test.User, test.Pass) + + if gotErr != nil { + if !test.WantError { + t.Errorf("did not expect error, but got: %v", gotErr) + } + if gotAuth { + t.Errorf("got error, so auth should not work, but got: %v", gotAuth) + } + } else { + if test.WantError { + t.Errorf("did expect error, but got none") + } + if gotAuth != test.Want { + t.Errorf("got auth %v but wanted %v", gotAuth, test.Want) + } + } + }) + } +} + +func TestNoAuthorizations(t *testing.T) { + t.Parallel() + + _, authErr := mapauth.New() + + if authErr == nil { + t.Errorf("expected error on creation, but got none") + } + + if !errors.Is(authErr, mapauth.ErrNoAuthorizations) { + t.Errorf("expected error to be ErrNoAuthorizations, but got: %v", authErr) + } +} + +func TestUninitializedAuthenticator(t *testing.T) { + t.Parallel() + + var auth *mapauth.MapAuthenticator + + _, authErr := auth.Authenticate("top", "secret") + + if authErr == nil { + t.Errorf("expected error on creation, but got none") + } + + if !errors.Is(authErr, mapauth.ErrNotInitialized) { + t.Errorf("expected error to be ErrUninitialized, but got: %v", authErr) + } +} diff --git a/handler/correlation/README.md b/handler/correlation/README.md index 6d99352..0cd8074 100644 --- a/handler/correlation/README.md +++ b/handler/correlation/README.md @@ -1,3 +1,7 @@ + + Correlation ID Middleware ========================= diff --git a/handler/correlation/base_test.go b/handler/correlation/base_test.go index 31e923f..7525682 100644 --- a/handler/correlation/base_test.go +++ b/handler/correlation/base_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package correlation +package correlation_test import ( "errors" @@ -11,7 +11,8 @@ import ( "os" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/correlation" + "github.com/AlphaOne1/midgard/helper" ) // @@ -19,15 +20,18 @@ import ( // func TestHandlerNil(t *testing.T) { - var handler *Handler + t.Parallel() + + var handler *correlation.Handler if got := handler.GetMWBase(); got != nil { t.Errorf("MWBase of nil must be nil, but got non-nil") } - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() + //goland:noinspection GoMaybeNil handler.ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusInternalServerError { @@ -40,11 +44,13 @@ func TestHandlerNil(t *testing.T) { // func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { + t.Parallel() + + errOpt := func( /* h */ *correlation.Handler) error { return errors.New("testerror") } - _, err := New(errOpt) + _, err := correlation.New(errOpt) if err == nil { t.Errorf("expected middleware creation to fail") @@ -52,7 +58,9 @@ func TestOptionError(t *testing.T) { } func TestOptionNil(t *testing.T) { - _, err := New(nil) + t.Parallel() + + _, err := correlation.New(nil) if err == nil { t.Errorf("expected middleware creation to fail") @@ -60,7 +68,9 @@ func TestOptionNil(t *testing.T) { } func TestHandlerNextNil(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(nil) + t.Parallel() + + h := helper.Must(correlation.New(correlation.WithLogLevel(slog.LevelDebug)))(nil) if h != nil { t.Errorf("expected handler to be nil") @@ -72,18 +82,28 @@ func TestHandlerNextNil(t *testing.T) { // func TestOptionWithLevel(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(util.DummyHandler)) + t.Parallel() + + h := helper.Must(correlation.New(correlation.WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*correlation.Handler) + + if !isValid { + t.Fatalf("wrong type") + } - if h.(*Handler).LogLevel() != slog.LevelDebug { + if val.LogLevel() != slog.LevelDebug { t.Errorf("wanted loglevel debug not set") } } func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) + t.Parallel() + + err := correlation.WithLogLevel(slog.LevelDebug)(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } @@ -92,25 +112,37 @@ func TestOptionWithLevelOnNil(t *testing.T) { // func TestOptionWithLogger(t *testing.T) { + t.Parallel() + l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New(WithLogger(l)))(http.HandlerFunc(util.DummyHandler)) + h := helper.Must(correlation.New(correlation.WithLogger(l)))(http.HandlerFunc(helper.DummyHandler)) - if h.(*Handler).Log() != l { + val, isValid := h.(*correlation.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != l { t.Errorf("logger not set correctly") } } func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) + t.Parallel() + + err := correlation.WithLogger(slog.Default())(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) + t.Parallel() + + var l *slog.Logger + _, hErr := correlation.New(correlation.WithLogger(l)) if hErr == nil { t.Errorf("expected error on configuration with nil logger") diff --git a/handler/correlation/correlation.go b/handler/correlation/correlation.go index 37edec3..1373021 100644 --- a/handler/correlation/correlation.go +++ b/handler/correlation/correlation.go @@ -1,6 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 +// Package correlation provides a middleware for adding correlation ids to HTTP requests. package correlation import ( @@ -9,13 +10,18 @@ import ( "net/http" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + +// Handler is the basic structure of the correlation id enriching middleware. type Handler struct { defs.MWBase } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -27,14 +33,14 @@ func (h *Handler) GetMWBase() *defs.MWBase { // ServeHTTP is implements the correlation id enriching middleware. // It adds an X-Correlation-ID header if none was present. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } correlationID := r.Header.Get("X-Correlation-ID") if correlationID == "" { - tmp := util.GetOrCreateID("") + tmp := helper.GetOrCreateID("") r.Header.Set("X-Correlation-ID", tmp) w.Header().Set("X-Correlation-ID", tmp) @@ -57,24 +63,25 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { return defs.WithLogLevel[*Handler](level) } -// New generates a new correlation id enriching middleware. +// New generates a new correlation-id-enriching middleware. func New(options ...func(*Handler) error) (defs.Middleware, error) { - h := &Handler{} + handler := &Handler{} for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(h); err != nil { + if err := opt(handler); err != nil { return nil, err } } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return h + + return handler }, nil } diff --git a/handler/correlation/correlation_test.go b/handler/correlation/correlation_test.go index 1e10e64..0484fbf 100644 --- a/handler/correlation/correlation_test.go +++ b/handler/correlation/correlation_test.go @@ -1,28 +1,31 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package correlation +package correlation_test import ( "net/http" "net/http/httptest" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/correlation" + "github.com/AlphaOne1/midgard/helper" ) func TestCorrelationNewID(t *testing.T) { + t.Parallel() + var gotCorrelationHeaderInside bool - insideHandler := func(w http.ResponseWriter, r *http.Request) { + insideHandler := func(_ http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Correlation-ID") != "" { gotCorrelationHeaderInside = true } } - handler := util.Must(New())(http.HandlerFunc(insideHandler)) + handler := helper.Must(correlation.New())(http.HandlerFunc(insideHandler)) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -31,23 +34,25 @@ func TestCorrelationNewID(t *testing.T) { t.Errorf("no X-Correlation-ID header added to request") } - if rec.Header().Get("X-Correlation-iD") == "" { + if rec.Header().Get("X-Correlation-ID") == "" { t.Errorf("no X-Correlation-ID header in response") } } func TestCorrelationSuppliedID(t *testing.T) { + t.Parallel() + var gotCorrelationHeaderInside bool - insideHandler := func(w http.ResponseWriter, r *http.Request) { + insideHandler := func(_ http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Correlation-ID") == "setOutside" { gotCorrelationHeaderInside = true } } - handler := util.Must(New())(http.HandlerFunc(insideHandler)) + handler := helper.Must(correlation.New())(http.HandlerFunc(insideHandler)) - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Add("X-Correlation-ID", "setOutside") rec := httptest.NewRecorder() @@ -57,7 +62,7 @@ func TestCorrelationSuppliedID(t *testing.T) { t.Errorf("X-Correlation-ID header not added correctly to request") } - if rec.Header().Get("X-Correlation-iD") != "setOutside" { + if rec.Header().Get("X-Correlation-ID") != "setOutside" { t.Errorf("X-Correlation-ID header not set correctly in response") } } diff --git a/handler/cors/README.md b/handler/cors/README.md index 9fe76fe..bf5630b 100644 --- a/handler/cors/README.md +++ b/handler/cors/README.md @@ -1,3 +1,7 @@ + + CORS Middleware =============== @@ -20,7 +24,7 @@ Example ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - util.Must(cors.New( + helper.Must(cors.New( cors.WithHeaders(cors.MinimumAllowHeaders()), cors.WithMethods([]string{http.MethodGet}), cors.WithOrigins([]string{"*"}))), diff --git a/handler/cors/base_test.go b/handler/cors/base_test.go index 7a2a0c1..ca3f2c8 100644 --- a/handler/cors/base_test.go +++ b/handler/cors/base_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package cors +package cors_test import ( "errors" @@ -11,7 +11,8 @@ import ( "os" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/cors" + "github.com/AlphaOne1/midgard/helper" ) // @@ -19,15 +20,18 @@ import ( // func TestHandlerNil(t *testing.T) { - var handler *Handler + t.Parallel() + + var handler *cors.Handler if got := handler.GetMWBase(); got != nil { t.Errorf("MWBase of nil must be nil, but got non-nil") } - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() + //goland:noinspection GoMaybeNil handler.ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusInternalServerError { @@ -40,11 +44,13 @@ func TestHandlerNil(t *testing.T) { // func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { + t.Parallel() + + errOpt := func( /* h */ *cors.Handler) error { return errors.New("testerror") } - _, err := New(errOpt) + _, err := cors.New(errOpt) if err == nil { t.Errorf("expected middleware creation to fail") @@ -52,7 +58,9 @@ func TestOptionError(t *testing.T) { } func TestOptionNil(t *testing.T) { - _, err := New(nil) + t.Parallel() + + _, err := cors.New(nil) if err == nil { t.Errorf("expected middleware creation to fail") @@ -60,7 +68,9 @@ func TestOptionNil(t *testing.T) { } func TestHandlerNextNil(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(nil) + t.Parallel() + + h := helper.Must(cors.New(cors.WithLogLevel(slog.LevelDebug)))(nil) if h != nil { t.Errorf("expected handler to be nil") @@ -72,18 +82,27 @@ func TestHandlerNextNil(t *testing.T) { // func TestOptionWithLevel(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(util.DummyHandler)) + t.Parallel() + + h := helper.Must(cors.New(cors.WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(helper.DummyHandler)) + val, isValid := h.(*cors.Handler) + + if !isValid { + t.Fatalf("wrong type") + } - if h.(*Handler).LogLevel() != slog.LevelDebug { + if val.LogLevel() != slog.LevelDebug { t.Errorf("wanted loglevel debug not set") } } func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) + t.Parallel() + + err := cors.WithLogLevel(slog.LevelDebug)(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } @@ -92,25 +111,37 @@ func TestOptionWithLevelOnNil(t *testing.T) { // func TestOptionWithLogger(t *testing.T) { + t.Parallel() + l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New(WithLogger(l)))(http.HandlerFunc(util.DummyHandler)) + h := helper.Must(cors.New(cors.WithLogger(l)))(http.HandlerFunc(helper.DummyHandler)) - if h.(*Handler).Log() != l { + val, isValid := h.(*cors.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != l { t.Errorf("logger not set correctly") } } func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) + t.Parallel() + + err := cors.WithLogger(slog.Default())(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) + t.Parallel() + + var l *slog.Logger + _, hErr := cors.New(cors.WithLogger(l)) if hErr == nil { t.Errorf("expected error on configuration with nil logger") diff --git a/handler/cors/cors.go b/handler/cors/cors.go index b793bf5..4ef4e00 100644 --- a/handler/cors/cors.go +++ b/handler/cors/cors.go @@ -1,23 +1,33 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 +// Package cors provides a middleware for handling CORS (Cross-Origin Resource Sharing) requests. package cors import ( "errors" - "fmt" "log/slog" "net/http" "slices" "strings" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) -// Handler is a middleware that sets up the cross site scripting circumvention headers. +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + +// ErrNoOrigin is returned when there is no origin in the request. +var ErrNoOrigin = errors.New("no origin in header") + +// ErrOriginNotAllowed is returned when the origin is not allowed. +var ErrOriginNotAllowed = errors.New("origin not allowed") + +// Handler is a middleware that sets up the cross-site scripting circumvention headers. type Handler struct { defs.MWBase + // Headers contains the allowed headers Headers map[string]bool // HeadersReturn contains the comma-concatenated allowed headers @@ -32,6 +42,7 @@ type Handler struct { Origins []string } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -40,21 +51,19 @@ func (h *Handler) GetMWBase() *defs.MWBase { return &h.MWBase } -var minimumAllowHeaders = []string{ - "Accept", - "Accept-Encoding", - "Authorization", - "Content-Length", - "Content-Type", - "Origin", - "User-Agent", - "X-CSRF-Token", -} - // MinimumAllowHeaders returns a minimal list of headers, that should not do // harm. It can be used to limit the allowed headers to a reasonable small set. func MinimumAllowHeaders() []string { - return append(make([]string, 0, len(minimumAllowHeaders)), minimumAllowHeaders...) + return []string{ + "Accept", + "Accept-Encoding", + "Authorization", + "Content-Length", + "Content-Type", + "Origin", + "User-Agent", + "X-CSRF-Token", + } } // relevantOrigin gets the origin that the client matches with the allowed origins. @@ -65,7 +74,7 @@ func relevantOrigin(origin []string, allowed []string) (string, error) { } if len(origin) == 0 { - return "", fmt.Errorf("no origin in header") + return "", ErrNoOrigin } for _, orig := range origin { @@ -78,12 +87,12 @@ func relevantOrigin(origin []string, allowed []string) (string, error) { } } - return "", fmt.Errorf("origin not allowed") + return "", ErrOriginNotAllowed } // ServeHTTP sets up the client with the appropriate headers. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } @@ -91,9 +100,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { relevantOrigin, roErr := relevantOrigin(origin, h.Origins) - // no relevant origin found in request + // no relevant origin found in the request if roErr != nil { - util.WriteState(w, h.Log(), http.StatusForbidden) + helper.WriteState(w, h.Log(), http.StatusForbidden) + return } @@ -105,12 +115,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Headers", h.HeadersReturn) w.WriteHeader(http.StatusOK) + return } // we have methods configured, but the request does not match any of them if len(h.Methods) > 0 && !h.Methods[r.Method] { - util.WriteState(w, h.Log(), http.StatusMethodNotAllowed) + helper.WriteState(w, h.Log(), http.StatusMethodNotAllowed) + return } @@ -119,7 +131,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(h.Headers) > 0 { for hdr := range r.Header { if !h.Headers[strings.ToLower(hdr)] { - util.WriteState(w, h.Log(), http.StatusForbidden) + helper.WriteState(w, h.Log(), http.StatusForbidden) + return } } @@ -183,33 +196,34 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { return defs.WithLogLevel[*Handler](level) } -// New sets up the cross site scripting circumvention disable headers. +// New sets up the cross-site scripting circumvention disable headers. // If no methods are specified, all methods are allowed. // If no headers are specified, all headers are allowed. // If origin contains "*" or is empty, the allowed origins are set to *. func New(options ...func(handler *Handler) error) (defs.Middleware, error) { - h := Handler{} + handler := Handler{} for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(&h); err != nil { + if err := opt(&handler); err != nil { return nil, err } } // if no origins are specified or one of the specified allowed origins is * // just set the origins to * - if len(h.Origins) == 0 || slices.Contains(h.Origins, "*") { - _ = WithOrigins([]string{"*"})(&h) + if len(handler.Origins) == 0 || slices.Contains(handler.Origins, "*") { + _ = WithOrigins([]string{"*"})(&handler) } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return &h + + return &handler }, nil } diff --git a/handler/cors/cors_test.go b/handler/cors/cors_test.go index 873e4d7..8273a29 100644 --- a/handler/cors/cors_test.go +++ b/handler/cors/cors_test.go @@ -1,19 +1,23 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package cors +package cors_test import ( + "fmt" "net/http" "net/http/httptest" "slices" "strings" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/cors" + "github.com/AlphaOne1/midgard/helper" ) func TestEvalCSSHandler(t *testing.T) { + t.Parallel() + tests := []struct { cssMethods []string cssOrigins []string @@ -115,36 +119,40 @@ func TestEvalCSSHandler(t *testing.T) { }, } - for k, v := range tests { - req, _ := http.NewRequest(v.method, "http://dummy.com:8080", strings.NewReader("")) + for k, test := range tests { + t.Run(fmt.Sprintf("TestEvalCSSHandler-%d", k), func(t *testing.T) { + t.Parallel() + + req, _ := http.NewRequestWithContext(t.Context(), test.method, "http://dummy.com:8080", strings.NewReader("")) - for hk, hv := range v.header { - for _, hvi := range hv { - req.Header.Add(hk, hvi) + for hk, hv := range test.header { + for _, hvi := range hv { + req.Header.Add(hk, hvi) + } } - } - rec := httptest.NewRecorder() + rec := httptest.NewRecorder() - mw := util.Must(New( - WithMethods(v.cssMethods), - WithHeaders(MinimumAllowHeaders()), - WithOrigins(v.cssOrigins)))(http.HandlerFunc(util.DummyHandler)) + mw := helper.Must(cors.New( + cors.WithMethods(test.cssMethods), + cors.WithHeaders(cors.MinimumAllowHeaders()), + cors.WithOrigins(test.cssOrigins)))(http.HandlerFunc(helper.DummyHandler)) - mw.ServeHTTP(rec, req) + mw.ServeHTTP(rec, req) - if rec.Code != v.wantCode { - t.Errorf("%v: css filter did not work as expected, wanted %v but got %v", k, v.wantCode, rec.Code) - } + if rec.Code != test.wantCode { + t.Errorf("css filter did not work as expected, wanted %v but got %v", test.wantCode, rec.Code) + } - if rec.Body.String() != v.wantContent { - t.Errorf("%v: wanted '%v' in body, but got '%v'", k, v.wantContent, rec.Body.String()) - } + if rec.Body.String() != test.wantContent { + t.Errorf("wanted '%v' in body, but got '%v'", test.wantContent, rec.Body.String()) + } - for wk, wv := range v.wantHeader { - if val, found := rec.Result().Header[wk]; !found || !slices.Contains(val, wv) { - t.Errorf("%v: wanted [%v:%v] but did not find it", k, wk, wv) + for wk, wv := range test.wantHeader { + if val, found := rec.Result().Header[wk]; !found || !slices.Contains(val, wv) { + t.Errorf("wanted [%v:%v] but did not find it", wk, wv) + } } - } + }) } } diff --git a/handler/method_filter/README.md b/handler/methodfilter/README.md similarity index 81% rename from handler/method_filter/README.md rename to handler/methodfilter/README.md index 1f3300f..e5d80c2 100644 --- a/handler/method_filter/README.md +++ b/handler/methodfilter/README.md @@ -1,3 +1,7 @@ + + Methods Filter Middleware ========================= @@ -23,8 +27,8 @@ Example ```go finalHandler := midgard.StackMiddlewareHandler( []midgard.Middleware{ - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet}))), + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet}))), }, http.HandlerFunc(HelloHandler), ) diff --git a/handler/access_log/base_test.go b/handler/methodfilter/base_test.go similarity index 50% rename from handler/access_log/base_test.go rename to handler/methodfilter/base_test.go index ce6db3c..3195700 100644 --- a/handler/access_log/base_test.go +++ b/handler/methodfilter/base_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package access_log +package methodfilter_test import ( "errors" @@ -11,7 +11,8 @@ import ( "os" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/methodfilter" + "github.com/AlphaOne1/midgard/helper" ) // @@ -19,15 +20,18 @@ import ( // func TestHandlerNil(t *testing.T) { - var handler *Handler + t.Parallel() + + var handler *methodfilter.Handler if got := handler.GetMWBase(); got != nil { t.Errorf("MWBase of nil must be nil, but got non-nil") } - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() + //goland:noinspection GoMaybeNil handler.ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusInternalServerError { @@ -40,11 +44,13 @@ func TestHandlerNil(t *testing.T) { // func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { + t.Parallel() + + errOpt := func( /* h */ *methodfilter.Handler) error { return errors.New("testerror") } - _, err := New(errOpt) + _, err := methodfilter.New(errOpt) if err == nil { t.Errorf("expected middleware creation to fail") @@ -52,7 +58,9 @@ func TestOptionError(t *testing.T) { } func TestOptionNil(t *testing.T) { - _, err := New(nil) + t.Parallel() + + _, err := methodfilter.New(nil) if err == nil { t.Errorf("expected middleware creation to fail") @@ -60,7 +68,9 @@ func TestOptionNil(t *testing.T) { } func TestHandlerNextNil(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(nil) + t.Parallel() + + h := helper.Must(methodfilter.New(methodfilter.WithLogLevel(slog.LevelDebug)))(nil) if h != nil { t.Errorf("expected handler to be nil") @@ -72,18 +82,28 @@ func TestHandlerNextNil(t *testing.T) { // func TestOptionWithLevel(t *testing.T) { - h := util.Must(New(WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(util.DummyHandler)) + t.Parallel() + + h := helper.Must(methodfilter.New(methodfilter.WithLogLevel(slog.LevelDebug)))(http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*methodfilter.Handler) + + if !isValid { + t.Fatalf("wrong type") + } - if h.(*Handler).LogLevel() != slog.LevelDebug { + if val.LogLevel() != slog.LevelDebug { t.Errorf("wanted loglevel debug not set") } } func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) + t.Parallel() + + err := methodfilter.WithLogLevel(slog.LevelDebug)(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } @@ -92,25 +112,37 @@ func TestOptionWithLevelOnNil(t *testing.T) { // func TestOptionWithLogger(t *testing.T) { + t.Parallel() + l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New(WithLogger(l)))(http.HandlerFunc(util.DummyHandler)) + h := helper.Must(methodfilter.New(methodfilter.WithLogger(l)))(http.HandlerFunc(helper.DummyHandler)) - if h.(*Handler).Log() != l { + val, isValid := h.(*methodfilter.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != l { t.Errorf("logger not set correctly") } } func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) + t.Parallel() + + err := methodfilter.WithLogger(slog.Default())(nil) if err == nil { - t.Errorf("expted error on configuring nil handler") + t.Errorf("expected error on configuring nil handler") } } func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) + t.Parallel() + + var l *slog.Logger + _, hErr := methodfilter.New(methodfilter.WithLogger(l)) if hErr == nil { t.Errorf("expected error on configuration with nil logger") diff --git a/handler/method_filter/methods_filter.go b/handler/methodfilter/methods_filter.go similarity index 72% rename from handler/method_filter/methods_filter.go rename to handler/methodfilter/methods_filter.go index e785c6f..056634b 100644 --- a/handler/method_filter/methods_filter.go +++ b/handler/methodfilter/methods_filter.go @@ -1,7 +1,8 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package method_filter +// Package methodfilter provides a middleware for filtering HTTP requests by methods. +package methodfilter import ( "errors" @@ -9,16 +10,21 @@ import ( "net/http" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + // Handler only lets configured HTTP methods pass. type Handler struct { defs.MWBase + // Methods contains methods whitelist for the endpoint. Methods map[string]bool } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -29,13 +35,14 @@ func (h *Handler) GetMWBase() *defs.MWBase { // ServeHTTP denies access (405) if the method is not in the whitelist. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } if h.Methods == nil { h.Log().Error("method filter not initialized") - util.WriteState(w, h.Log(), http.StatusServiceUnavailable) + helper.WriteState(w, h.Log(), http.StatusServiceUnavailable) + return } @@ -43,7 +50,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Next().ServeHTTP(w, r) } - util.WriteState(w, h.Log(), http.StatusMethodNotAllowed) + helper.WriteState(w, h.Log(), http.StatusMethodNotAllowed) } // WithMethods sets the methods_filter configuration to allow the given methods to pass. If used multiple times, @@ -74,22 +81,23 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { // New sets up the method filter middleware. Its parameters are functions manipulating an internal Config variable. func New(options ...func(m *Handler) error) (defs.Middleware, error) { - h := Handler{} + handler := Handler{} for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(&h); err != nil { + if err := opt(&handler); err != nil { return nil, err } } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return &h + + return &handler }, nil } diff --git a/handler/method_filter/methods_filter_test.go b/handler/methodfilter/methods_filter_test.go similarity index 59% rename from handler/method_filter/methods_filter_test.go rename to handler/methodfilter/methods_filter_test.go index 44d7b09..acd6952 100644 --- a/handler/method_filter/methods_filter_test.go +++ b/handler/methodfilter/methods_filter_test.go @@ -1,18 +1,22 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package method_filter +package methodfilter_test import ( + "fmt" "net/http" "net/http/httptest" "strings" "testing" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/methodfilter" + "github.com/AlphaOne1/midgard/helper" ) func TestMethodFilter(t *testing.T) { + t.Parallel() + tests := []struct { filter []string method string @@ -50,29 +54,35 @@ func TestMethodFilter(t *testing.T) { }, } - for k, v := range tests { - req, _ := http.NewRequest(http.MethodGet, "", strings.NewReader("")) - // set method after, as Go could change it - req.Method = v.method - rec := httptest.NewRecorder() + for k, test := range tests { + t.Run(fmt.Sprintf("TestMethodFilter-%d", k), func(t *testing.T) { + t.Parallel() - mw := util.Must(New(WithMethods(v.filter)))(http.HandlerFunc(util.DummyHandler)) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "", strings.NewReader("")) + // set the method after, as Go could change it + req.Method = test.method + rec := httptest.NewRecorder() - mw.ServeHTTP(rec, req) + mw := helper.Must(methodfilter.New(methodfilter.WithMethods(test.filter)))(http.HandlerFunc(helper.DummyHandler)) - if rec.Code != v.wantCode { - t.Errorf("%v: method filter did not work as expected, wanted %v but got %v", k, v.wantCode, rec.Code) - } + mw.ServeHTTP(rec, req) + + if rec.Code != test.wantCode { + t.Errorf("method filter did not work as expected, wanted %v but got %v", test.wantCode, rec.Code) + } + }) } } func TestMethodFilterUninitialized(t *testing.T) { - req, _ := http.NewRequest(http.MethodGet, "", strings.NewReader("")) - // set method after, as Go could change it + t.Parallel() + + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "", strings.NewReader("")) + // set the method after, as Go could change it req.Method = http.MethodGet rec := httptest.NewRecorder() - mw := util.Must(New())(http.HandlerFunc(util.DummyHandler)) + mw := helper.Must(methodfilter.New())(http.HandlerFunc(helper.DummyHandler)) mw.ServeHTTP(rec, req) @@ -82,7 +92,6 @@ func TestMethodFilterUninitialized(t *testing.T) { } func FuzzMethodFilter(f *testing.F) { - f.Add(http.MethodDelete) f.Add(http.MethodGet) f.Add(http.MethodOptions) @@ -90,10 +99,11 @@ func FuzzMethodFilter(f *testing.F) { f.Add(http.MethodPut) activeFilter := map[string]bool{http.MethodOptions: true, http.MethodGet: true} - mw := util.Must(New(WithMethods(util.MapKeys(activeFilter))))(http.HandlerFunc(util.DummyHandler)) + mw := helper.Must(methodfilter.New( + methodfilter.WithMethods(helper.MapKeys(activeFilter))))(http.HandlerFunc(helper.DummyHandler)) f.Fuzz(func(t *testing.T, method string) { - req, _ := http.NewRequest(http.MethodGet, "", strings.NewReader("")) + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "", strings.NewReader("")) req.Method = method rec := httptest.NewRecorder() @@ -101,6 +111,7 @@ func FuzzMethodFilter(f *testing.F) { if activeFilter[method] && rec.Code != http.StatusOK || !activeFilter[method] && rec.Code != http.StatusMethodNotAllowed { + t.Errorf("method filter did not work as expected, method %v got %v", method, rec.Code) } }) diff --git a/handler/rate_limit/README.md b/handler/rate_limit/README.md deleted file mode 100644 index 2953ae7..0000000 --- a/handler/rate_limit/README.md +++ /dev/null @@ -1,15 +0,0 @@ -Rate Limiter -============ - -_Rate Limiter_ is a request rate limiting middleware. It uses a _Limiter_ to do -the heavy lifting. - -Example -------- - -```go -rl := New(WithLimiter(local_limit.New( - local_limit.WithTargetRate(10))), - local_limit.WithSleepInterval(100*time.Millisecond), -) -``` diff --git a/handler/rate_limit/base_test.go b/handler/rate_limit/base_test.go deleted file mode 100644 index d780d79..0000000 --- a/handler/rate_limit/base_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package rate_limit - -import ( - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/AlphaOne1/midgard/handler/rate_limit/local_limit" - "github.com/AlphaOne1/midgard/util" -) - -// -// Basic Handler -// - -func TestHandlerNil(t *testing.T) { - var handler *Handler - - if got := handler.GetMWBase(); got != nil { - t.Errorf("MWBase of nil must be nil, but got non-nil") - } - - req := httptest.NewRequest("GET", "/", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - if rec.Result().StatusCode != http.StatusInternalServerError { - t.Errorf("expected %v but got %v", http.StatusInternalServerError, rec.Result().StatusCode) - } -} - -// -// Generic Options -// - -func TestOptionError(t *testing.T) { - errOpt := func(h *Handler) error { - return errors.New("testerror") - } - - _, err := New(errOpt) - - if err == nil { - t.Errorf("expected middleware creation to fail") - } -} - -func TestOptionNil(t *testing.T) { - _, err := New(nil) - - if err == nil { - t.Errorf("expected middleware creation to fail") - } -} - -func TestHandlerNextNil(t *testing.T) { - h := util.Must(New( - WithLogLevel(slog.LevelDebug), - WithLimiter(util.Must(local_limit.New()))))( - nil) - - if h != nil { - t.Errorf("expected handler to be nil") - } -} - -// -// WithLevel -// - -func TestOptionWithLevel(t *testing.T) { - h := util.Must(New( - WithLogLevel(slog.LevelDebug), - WithLimiter(util.Must(local_limit.New()))))( - http.HandlerFunc(util.DummyHandler)) - - if h.(*Handler).LogLevel() != slog.LevelDebug { - t.Errorf("wanted loglevel debug not set") - } -} - -func TestOptionWithLevelOnNil(t *testing.T) { - err := WithLogLevel(slog.LevelDebug)(nil) - - if err == nil { - t.Errorf("expted error on configuring nil handler") - } -} - -// -// WithLogger -// - -func TestOptionWithLogger(t *testing.T) { - l := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - h := util.Must(New( - WithLogger(l), - WithLimiter(util.Must(local_limit.New()))))( - http.HandlerFunc(util.DummyHandler)) - - if h.(*Handler).Log() != l { - t.Errorf("logger not set correctly") - } -} - -func TestOptionWithLoggerOnNil(t *testing.T) { - err := WithLogger(slog.Default())(nil) - - if err == nil { - t.Errorf("expted error on configuring nil handler") - } -} - -func TestOptionWithNilLogger(t *testing.T) { - var l *slog.Logger = nil - _, hErr := New(WithLogger(l)) - - if hErr == nil { - t.Errorf("expected error on configuration with nil logger") - } -} diff --git a/handler/rate_limit/local_limit/local_limit_test.go b/handler/rate_limit/local_limit/local_limit_test.go deleted file mode 100644 index 6d05258..0000000 --- a/handler/rate_limit/local_limit/local_limit_test.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package local_limit - -import ( - "testing" - "time" - - "github.com/AlphaOne1/midgard/util" -) - -func TestLocalLimitRate(t *testing.T) { - tests := []struct { - TargetRate float64 - SleepTime time.Duration - TestDuration time.Duration - WantDrops int - }{ - { - TargetRate: 1, - SleepTime: 1 * time.Second, - TestDuration: 3 * time.Second, - WantDrops: 3, - }, - { - TargetRate: 100, - SleepTime: 200 * time.Millisecond, - TestDuration: 420 * time.Millisecond, - WantDrops: 40, - }, - { - TargetRate: 0.5, - SleepTime: 200 * time.Millisecond, - TestDuration: 2100 * time.Millisecond, - WantDrops: 1, - }, - } - - for k, v := range tests { - got := 0 - - limiter := util.Must(New( - WithTargetRate(v.TargetRate), - WithSleepInterval(v.SleepTime))) - - startTime := time.Now() - - for time.Since(startTime) < v.TestDuration { - if limiter.Limit() { - got++ - } - } - - if got < v.WantDrops-1 || got > v.WantDrops+1 { - t.Errorf("%v: got %v drops but wanted %v", k, got, v.WantDrops) - } - - limiter.Stop() - } -} - -func TestMaxDropsAbsolute(t *testing.T) { - tests := []struct { - want int64 - }{ - {want: 23}, - {want: 42}, - {want: 123}, - } - - for k, v := range tests { - h := util.Must(New(WithMaxDropsAbsolute(v.want))) - - if h.MaxDrops != v.want { - t.Errorf("%v: got %v wanted %v", k, h.MaxDrops, v.want) - } - } -} - -func TestMaxDropsInterval(t *testing.T) { - tests := []struct { - want time.Duration - }{ - {want: 1 * time.Second}, - {want: 10 * time.Second}, - {want: 1400 * time.Millisecond}, - } - - for k, v := range tests { - h := util.Must(New( - WithTargetRate(100), - WithMaxDropsInterval(v.want))) - - wantAbsolute := int64(v.want.Seconds() * h.TargetRate) - - if h.MaxDrops != wantAbsolute { - t.Errorf("%v: got %v wanted %v", k, h.MaxDrops, wantAbsolute) - } - } -} - -func TestWithSleepInterval(t *testing.T) { - tests := []struct { - want time.Duration - wantErr bool - }{ - { - want: 10 * time.Millisecond, - wantErr: false, - }, - { - want: 200 * time.Millisecond, - wantErr: false, - }, - { - want: 1400 * time.Millisecond, - wantErr: false, - }, - { - want: 0, - wantErr: true, - }, - } - - for k, v := range tests { - h, hErr := New(WithSleepInterval(v.want)) - - if (hErr != nil) != v.wantErr { - t.Errorf("%v: got error %v, want error %v", k, hErr, v.wantErr) - } - - if hErr == nil && h.SleepInterval != v.want { - t.Errorf("%v: got %v wanted %v", k, h.SleepInterval, v.want) - } - } -} - -func TestWithDropTimeout(t *testing.T) { - tests := []struct { - want time.Duration - }{ - {want: 1 * time.Second}, - {want: 2 * time.Second}, - {want: 400 * time.Millisecond}, - } - - for k, v := range tests { - h := util.Must(New( - WithTargetRate(0.1), - WithDropTimeout(v.want))) - - startTime := time.Now() - h.Limit() - duration := time.Since(startTime) - h.Stop() - - if duration < time.Duration(float64(v.want)*0.95) || - duration > time.Duration(float64(v.want)*1.05) { - t.Errorf("%v: used %v but the timeout was %v", k, duration, v.want) - } - - } -} diff --git a/handler/ratelimit/README.md b/handler/ratelimit/README.md new file mode 100644 index 0000000..62d881e --- /dev/null +++ b/handler/ratelimit/README.md @@ -0,0 +1,19 @@ + + +Rate Limiter +============ + +_Rate Limiter_ is a request rate limiting middleware. It uses a _Limiter_ to do +the heavy lifting. + +Example +------- + +```go +rl := ratelimit.New( + ratelimit.WithLimiter(locallimit.New( + locallimit.WithTargetRate(10), + locallimit.WithSleepInterval(100*time.Millisecond)))) +``` diff --git a/handler/ratelimit/base_test.go b/handler/ratelimit/base_test.go new file mode 100644 index 0000000..4b1ffbd --- /dev/null +++ b/handler/ratelimit/base_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package ratelimit_test + +import ( + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/AlphaOne1/midgard/handler/ratelimit" + "github.com/AlphaOne1/midgard/handler/ratelimit/locallimit" + "github.com/AlphaOne1/midgard/helper" +) + +// +// Basic Handler +// + +func TestHandlerNil(t *testing.T) { + t.Parallel() + + var handler *ratelimit.Handler + + if got := handler.GetMWBase(); got != nil { + t.Errorf("MWBase of nil must be nil, but got non-nil") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + //goland:noinspection GoMaybeNil + handler.ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusInternalServerError { + t.Errorf("expected %v but got %v", http.StatusInternalServerError, rec.Result().StatusCode) + } +} + +// +// Generic Options +// + +func TestOptionError(t *testing.T) { + t.Parallel() + + errOpt := func( /* h */ *ratelimit.Handler) error { + return errors.New("testerror") + } + + _, err := ratelimit.New(errOpt) + + if err == nil { + t.Errorf("expected middleware creation to fail") + } +} + +func TestOptionNil(t *testing.T) { + t.Parallel() + + _, err := ratelimit.New(nil) + + if err == nil { + t.Errorf("expected middleware creation to fail") + } +} + +func TestHandlerNextNil(t *testing.T) { + t.Parallel() + + h := helper.Must(ratelimit.New( + ratelimit.WithLogLevel(slog.LevelDebug), + ratelimit.WithLimiter(helper.Must(locallimit.New()))))( + nil) + + if h != nil { + t.Errorf("expected handler to be nil") + } +} + +// +// WithLevel +// + +func TestOptionWithLevel(t *testing.T) { + t.Parallel() + + h := helper.Must(ratelimit.New( + ratelimit.WithLogLevel(slog.LevelDebug), + ratelimit.WithLimiter(helper.Must(locallimit.New()))))( + http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*ratelimit.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.LogLevel() != slog.LevelDebug { + t.Errorf("wanted loglevel debug not set") + } +} + +func TestOptionWithLevelOnNil(t *testing.T) { + t.Parallel() + + err := ratelimit.WithLogLevel(slog.LevelDebug)(nil) + + if err == nil { + t.Errorf("expected error on configuring nil handler") + } +} + +// +// WithLogger +// + +func TestOptionWithLogger(t *testing.T) { + t.Parallel() + + newLog := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + h := helper.Must(ratelimit.New( + ratelimit.WithLogger(newLog), + ratelimit.WithLimiter(helper.Must(locallimit.New()))))( + http.HandlerFunc(helper.DummyHandler)) + + val, isValid := h.(*ratelimit.Handler) + + if !isValid { + t.Fatalf("wrong type") + } + + if val.Log() != newLog { + t.Errorf("logger not set correctly") + } +} + +func TestOptionWithLoggerOnNil(t *testing.T) { + t.Parallel() + + err := ratelimit.WithLogger(slog.Default())(nil) + + if err == nil { + t.Errorf("expected error on configuring nil handler") + } +} + +func TestOptionWithNilLogger(t *testing.T) { + t.Parallel() + + var l *slog.Logger + _, hErr := ratelimit.New(ratelimit.WithLogger(l)) + + if hErr == nil { + t.Errorf("expected error on configuration with nil logger") + } +} diff --git a/handler/rate_limit/local_limit/README.md b/handler/ratelimit/locallimit/README.md similarity index 60% rename from handler/rate_limit/local_limit/README.md rename to handler/ratelimit/locallimit/README.md index 31d21a8..9fe8531 100644 --- a/handler/rate_limit/local_limit/README.md +++ b/handler/ratelimit/locallimit/README.md @@ -1,17 +1,22 @@ + + Local Limit =========== -_Local Limit_ is a rate limiter that works on instance level. It is not intended -to limit the global request rate to numerous instances of a service. This can be -achieved using a somehow synchronized rate limiting algorithm, e.g. via a redis. +_Local Limit_ is a rate limiter that works on the instance level. It is not +intended to limit the global request rate to numerous instances of a service. +This can be achieved using a somehow synchronized rate-limiting algorithm, e.g. +via a redis. _Local Limit_ allows a certain number of requests (called drops here) per second to pass. Inside, it uses a dripping like algorithm to add possible requests to a queue. Drops are added in certain intervals. Drop rates less than one per second are possible, as fractional drops are stored for the next iteration. -Every incoming request takes one (whole) drop out of the queue of drops, until -it is empty. If there a no drops available, a request waits for an arbitrary +Every incoming request takes one (whole) drop out of the queue of drops until +it is empty. If there are no drops available, a request waits for an arbitrary time for a new drop. If none arrives, the request is rejected. Drops can accumulate to a specified maximum. So services will not be overwhelmed @@ -25,9 +30,9 @@ Example ------- ```go -limiter := util.Must(New( - WithTargetRate(v.TargetRate), - WithDefaultSleepInterval(v.SleepTime))) +limiter := helper.Must(locallimit.New( + locallimit.WithTargetRate(v.TargetRate), + locallimit.WithSleepInterval(v.SleepTime))) if limiter.Limit() { doWork() diff --git a/handler/rate_limit/local_limit/local_limit.go b/handler/ratelimit/locallimit/local_limit.go similarity index 79% rename from handler/rate_limit/local_limit/local_limit.go rename to handler/ratelimit/locallimit/local_limit.go index d18612d..7e173be 100644 --- a/handler/rate_limit/local_limit/local_limit.go +++ b/handler/ratelimit/locallimit/local_limit.go @@ -1,15 +1,23 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package local_limit +// Package locallimit provides a process-local rate limiter. +package locallimit import ( "errors" "sync" + "sync/atomic" "time" ) -// drop is an empty structure to be used as drops in the internal algorithm +// ErrZeroRate is returned when the rate is 0. +var ErrZeroRate = errors.New("rate must be greater than 0") + +// ErrZeroSleepTime is returned when the sleep time is 0. +var ErrZeroSleepTime = errors.New("sleep time must be greater than 0") + +// drop is an empty structure to be used as drops in the internal algorithm. type drop struct{} // LocalLimit is a instance local request limiter. @@ -32,9 +40,9 @@ type LocalLimit struct { // SleepInterval is the time between the generation of drops SleepInterval time.Duration // dropStarted signalizes if the drop generation has been started. - dropStarted bool + dropStarted atomic.Bool // stop signals the internal drop generator to stop working - stop bool + stop atomic.Bool // overflow stores the fractional drops, especially with a low TargetRate this // guarantees no lost drops. overflow float64 @@ -44,24 +52,47 @@ type LocalLimit struct { lastIter time.Time } +// Stop sets the stop marker, so the drop generator can stop eventually. +func (l *LocalLimit) Stop() { + l.stop.Store(true) +} + +// Limit gives true, if the rate limit is not yet exceeded, otherwise false. +// If there are currently no drops to exhaust, it will wait the configured +// DropTimeout for a drop. +func (l *LocalLimit) Limit() bool { + if !l.dropStarted.Load() { + l.dropStartOnce.Do(func() { go l.run() }) + } + + dropped := false + + select { + case <-l.drops: + dropped = true + case <-time.After(l.DropTimeout): + } + + return dropped +} + // run generates the drops. It is called internally as a go routine. func (l *LocalLimit) run() { - l.dropStarted = true + l.dropStarted.Store(true) l.lastIter = time.Now() var iterTime time.Duration var drops float64 var fillDrops int64 - var i int64 - for !l.stop { + for !l.stop.Load() { time.Sleep(l.SleepInterval) iterTime = time.Since(l.lastIter) drops = iterTime.Seconds()*l.TargetRate + l.overflow fillDrops = min(l.MaxDrops-int64(len(l.drops)), int64(drops)) - for i = 0; i < fillDrops; i++ { + for range fillDrops { l.drops <- drop{} } @@ -76,30 +107,6 @@ func (l *LocalLimit) run() { } } -// Stop sets the stop marker, so the drop generator can stop eventually. -func (l *LocalLimit) Stop() { - l.stop = true -} - -// Limit gives true, if the rate limit is not yet exceeded, otherwise false. -// If there are currently no drops to exhaust, it will wait the configured -// DropTimeout for a drop. -func (l *LocalLimit) Limit() bool { - if !l.dropStarted { - l.dropStartOnce.Do(func() { go l.run() }) - } - - dropped := false - - select { - case <-l.drops: - dropped = true - case <-time.After(l.DropTimeout): - } - - return dropped -} - // WithDropTimeout sets the timeout a process calling Limit will wait, before // giving up to get a drop. func WithDropTimeout(d time.Duration) func(l *LocalLimit) error { @@ -116,6 +123,7 @@ func WithDropTimeout(d time.Duration) func(l *LocalLimit) error { func WithMaxDropsAbsolute(d int64) func(l *LocalLimit) error { return func(l *LocalLimit) error { l.MaxDrops = d + return nil } } @@ -123,11 +131,12 @@ func WithMaxDropsAbsolute(d int64) func(l *LocalLimit) error { // WithMaxDropsInterval sets the maximum number of drops to be stored before // dismissing them. Other than WithMaxDropsAbsolute it calculates the number of // drops to store depending on the TargetRate parameter. -// This methods must be called after setting the TargetRate. Otherwise the defaults +// This method must be called after setting the TargetRate. Otherwise, the defaults // are used. func WithMaxDropsInterval(d time.Duration) func(l *LocalLimit) error { return func(l *LocalLimit) error { l.MaxDrops = max(1, int64(l.TargetRate*d.Seconds())) + return nil } } @@ -138,7 +147,7 @@ func WithMaxDropsInterval(d time.Duration) func(l *LocalLimit) error { func WithSleepInterval(i time.Duration) func(l *LocalLimit) error { return func(l *LocalLimit) error { if i <= 0 { - return errors.New("sleep time must be greater than 0") + return ErrZeroSleepTime } l.SleepInterval = i @@ -151,7 +160,7 @@ func WithSleepInterval(i time.Duration) func(l *LocalLimit) error { func WithTargetRate(r float64) func(l *LocalLimit) error { return func(l *LocalLimit) error { if r <= 0 { - return errors.New("rate must be greater than 0") + return ErrZeroRate } l.TargetRate = r @@ -162,20 +171,24 @@ func WithTargetRate(r float64) func(l *LocalLimit) error { // New creates a new local rate limiter. func New(options ...func(*LocalLimit) error) (*LocalLimit, error) { - l := LocalLimit{ + const DefaultSleepInterval = 100 * time.Millisecond + const DefaultDropTimeout = 100 * time.Millisecond + const DefaultMaxDrops = 1_000 + + limit := LocalLimit{ TargetRate: 1, - SleepInterval: 100 * time.Millisecond, - DropTimeout: 150 * time.Millisecond, + SleepInterval: DefaultSleepInterval, + DropTimeout: DefaultDropTimeout, drops: make(chan drop), - MaxDrops: 1_000, - dropStarted: false, + MaxDrops: DefaultMaxDrops, + dropStarted: atomic.Bool{}, } for _, opt := range options { - if err := opt(&l); err != nil { + if err := opt(&limit); err != nil { return nil, err } } - return &l, nil + return &limit, nil } diff --git a/handler/ratelimit/locallimit/local_limit_test.go b/handler/ratelimit/locallimit/local_limit_test.go new file mode 100644 index 0000000..30b8ce9 --- /dev/null +++ b/handler/ratelimit/locallimit/local_limit_test.go @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package locallimit_test + +import ( + "fmt" + "testing" + "time" + + "github.com/AlphaOne1/midgard/handler/ratelimit/locallimit" + "github.com/AlphaOne1/midgard/helper" +) + +func TestLocalLimitRate(t *testing.T) { + t.Parallel() + + tests := []struct { + TargetRate float64 + SleepTime time.Duration + TestDuration time.Duration + WantDrops int + }{ + { + TargetRate: 1, + SleepTime: 1 * time.Second, + TestDuration: 3 * time.Second, + WantDrops: 3, + }, + { + TargetRate: 100, + SleepTime: 200 * time.Millisecond, + TestDuration: 420 * time.Millisecond, + WantDrops: 40, + }, + { + TargetRate: 0.5, + SleepTime: 200 * time.Millisecond, + TestDuration: 2100 * time.Millisecond, + WantDrops: 1, + }, + } + + for k, test := range tests { + t.Run(fmt.Sprintf("TestLocalLimitRate-%d", k), func(t *testing.T) { + t.Parallel() + + got := 0 + + limiter := helper.Must(locallimit.New( + locallimit.WithTargetRate(test.TargetRate), + locallimit.WithSleepInterval(test.SleepTime))) + + startTime := time.Now() + + for time.Since(startTime) < test.TestDuration { + if limiter.Limit() { + got++ + } + } + + if got < test.WantDrops-1 || got > test.WantDrops+1 { + t.Errorf("got %v drops but wanted %v", got, test.WantDrops) + } + + limiter.Stop() + }) + } +} + +func TestMaxDropsAbsolute(t *testing.T) { + t.Parallel() + + tests := []struct { + want int64 + }{ + {want: 23}, + {want: 42}, + {want: 123}, + } + + for k, v := range tests { + h := helper.Must(locallimit.New(locallimit.WithMaxDropsAbsolute(v.want))) + t.Cleanup(h.Stop) + + if h.MaxDrops != v.want { + t.Errorf("%v: got %v wanted %v", k, h.MaxDrops, v.want) + } + } +} + +func TestMaxDropsInterval(t *testing.T) { + t.Parallel() + + tests := []struct { + want time.Duration + }{ + {want: 1 * time.Second}, + {want: 10 * time.Second}, + {want: 1400 * time.Millisecond}, + } + + for k, v := range tests { + t.Run(fmt.Sprintf("TestMaxDropsInterval-%d", k), func(t *testing.T) { + t.Parallel() + + h := helper.Must(locallimit.New( + locallimit.WithTargetRate(100), + locallimit.WithMaxDropsInterval(v.want))) + + wantAbsolute := int64(v.want.Seconds() * h.TargetRate) + + if h.MaxDrops != wantAbsolute { + t.Errorf("got %v wanted %v", h.MaxDrops, wantAbsolute) + } + }) + } +} + +func TestWithSleepInterval(t *testing.T) { + t.Parallel() + + tests := []struct { + want time.Duration + wantErr bool + }{ + { + want: 10 * time.Millisecond, + wantErr: false, + }, + { + want: 200 * time.Millisecond, + wantErr: false, + }, + { + want: 1400 * time.Millisecond, + wantErr: false, + }, + { + want: 0, + wantErr: true, + }, + } + + for k, test := range tests { + t.Run(fmt.Sprintf("TestWithSleepInterval-%d", k), func(t *testing.T) { + t.Parallel() + + h, hErr := locallimit.New(locallimit.WithSleepInterval(test.want)) + + if (hErr != nil) != test.wantErr { + t.Errorf("got error %v, want error %v", hErr, test.wantErr) + } + + if hErr == nil { + t.Cleanup(h.Stop) + + if h.SleepInterval != test.want { + t.Errorf("got %v wanted %v", h.SleepInterval, test.want) + } + } + }) + } +} + +func TestWithDropTimeout(t *testing.T) { + t.Parallel() + + tests := []struct { + want time.Duration + }{ + {want: 1 * time.Second}, + {want: 2 * time.Second}, + {want: 400 * time.Millisecond}, + } + + for k, test := range tests { + t.Run(fmt.Sprintf("TestWithDropTimeout-%d", k), func(t *testing.T) { + t.Parallel() + + h := helper.Must(locallimit.New( + locallimit.WithTargetRate(0.1), + locallimit.WithDropTimeout(test.want))) + + startTime := time.Now() + h.Limit() + duration := time.Since(startTime) + h.Stop() + + if duration < time.Duration(float64(test.want)*0.95) || + duration > time.Duration(float64(test.want)*1.05) { + + t.Errorf("used %v but the timeout was %v", duration, test.want) + } + }) + } +} diff --git a/handler/rate_limit/rate_limit.go b/handler/ratelimit/rate_limit.go similarity index 64% rename from handler/rate_limit/rate_limit.go rename to handler/ratelimit/rate_limit.go index dcd8e50..2936a3c 100644 --- a/handler/rate_limit/rate_limit.go +++ b/handler/ratelimit/rate_limit.go @@ -1,7 +1,8 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package rate_limit +// Package ratelimit provides middleware for rate limiting HTTP requests. +package ratelimit import ( "errors" @@ -9,9 +10,15 @@ import ( "net/http" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/helper" ) +// ErrInvalidLimiter is returned when the given limiter is invalid. +var ErrInvalidLimiter = errors.New("invalid limiter") + +// ErrNilOption is returned when an option is nil. +var ErrNilOption = errors.New("option cannot be nil") + // Limiter is the interface a limiter has to implement to be used in the rate // limiter middleware. type Limiter interface { @@ -21,9 +28,11 @@ type Limiter interface { // Handler holds the internal rate limiter information. type Handler struct { defs.MWBase + Limit Limiter } +// GetMWBase returns the MWBase instance of the handler. func (h *Handler) GetMWBase() *defs.MWBase { if h == nil { return nil @@ -34,12 +43,13 @@ func (h *Handler) GetMWBase() *defs.MWBase { // ServeHTTP limits the requests using the internal Limiter. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if !util.IntroCheck(h, w, r) { + if !helper.IntroCheck(h, w, r) { return } if !h.Limit.Limit() { - util.WriteState(w, h.Log(), http.StatusTooManyRequests) + helper.WriteState(w, h.Log(), http.StatusTooManyRequests) + return } @@ -50,7 +60,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func WithLimiter(l Limiter) func(h *Handler) error { return func(h *Handler) error { if l == nil { - return errors.New("invalid limiter (nil)") + return ErrInvalidLimiter } h.Limit = l @@ -71,26 +81,27 @@ func WithLogLevel(level slog.Level) func(h *Handler) error { // New creates a new rate limiter middleware. func New(options ...func(*Handler) error) (defs.Middleware, error) { - h := Handler{} + handler := Handler{} for _, opt := range options { if opt == nil { - return nil, errors.New("options cannot be nil") + return nil, ErrNilOption } - if err := opt(&h); err != nil { + if err := opt(&handler); err != nil { return nil, err } } - if h.Limit == nil { - return nil, errors.New("invalid limiter (nil)") + if handler.Limit == nil { + return nil, ErrInvalidLimiter } return func(next http.Handler) http.Handler { - if err := h.SetNext(next); err != nil { + if err := handler.SetNext(next); err != nil { return nil } - return &h + + return &handler }, nil } diff --git a/handler/rate_limit/rate_limit_test.go b/handler/ratelimit/rate_limit_test.go similarity index 53% rename from handler/rate_limit/rate_limit_test.go rename to handler/ratelimit/rate_limit_test.go index b328963..fddceec 100644 --- a/handler/rate_limit/rate_limit_test.go +++ b/handler/ratelimit/rate_limit_test.go @@ -1,7 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package rate_limit +package ratelimit_test import ( "net/http" @@ -11,25 +11,27 @@ import ( "github.com/AlphaOne1/midgard" "github.com/AlphaOne1/midgard/defs" - "github.com/AlphaOne1/midgard/handler/rate_limit/local_limit" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/ratelimit" + "github.com/AlphaOne1/midgard/handler/ratelimit/locallimit" + "github.com/AlphaOne1/midgard/helper" ) func TestRateLimit(t *testing.T) { + t.Parallel() handler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(New(WithLimiter( - util.Must(local_limit.New( - local_limit.WithTargetRate(20), - local_limit.WithDropTimeout(15*time.Millisecond), - local_limit.WithSleepInterval(100*time.Millisecond)))))), + helper.Must(ratelimit.New(ratelimit.WithLimiter( + helper.Must(locallimit.New( + locallimit.WithTargetRate(20), + locallimit.WithDropTimeout(15*time.Millisecond), + locallimit.WithSleepInterval(100*time.Millisecond)))))), }, - http.HandlerFunc(util.DummyHandler)) + http.HandlerFunc(helper.DummyHandler)) got := 0 - for i := 0; i < 30; i++ { + for range 30 { req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() @@ -46,7 +48,9 @@ func TestRateLimit(t *testing.T) { } func TestNoOptions(t *testing.T) { - _, err := New() + t.Parallel() + + _, err := ratelimit.New() if err == nil { t.Errorf("expected middleware creation to fail") @@ -54,7 +58,9 @@ func TestNoOptions(t *testing.T) { } func TestNilLimiter(t *testing.T) { - _, err := New(WithLimiter(nil)) + t.Parallel() + + _, err := ratelimit.New(ratelimit.WithLimiter(nil)) if err == nil { t.Errorf("expected middleware creation to fail") diff --git a/util/util.go b/helper/helper.go similarity index 66% rename from util/util.go rename to helper/helper.go index 6e6e9e7..0a2a92a 100644 --- a/util/util.go +++ b/helper/helper.go @@ -1,9 +1,11 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package util +// Package helper provides utility functions for the midgard package. +package helper import ( + "fmt" "log/slog" "net/http" "os" @@ -16,11 +18,11 @@ import ( // exitFunc is used to exit the program. For testing purposes it can be set to another function suitable // for non-exiting tests. Do not touch, if insecure! -var exitFunc = os.Exit +var exitFunc = os.Exit //nolint:gochecknoglobals -// Must exits the program if the given pair of function return and error contains an non-nil error value, +// Must exits the program if the given pair of function return and error contains a non-nil error value, // otherwise the function return value val is returned. -func Must[T any](val T, err error) T { +func Must[T any](val T, err error) T { //nolint:ireturn // as a generic, it must return the type that way if err != nil { slog.Error("must-condition not met", slog.String("error", err.Error())) @@ -31,28 +33,43 @@ func Must[T any](val T, err error) T { return val } +// WrapIfError wraps an existing error with the provided text. +// It returns nil if `err` is nil, or the original `err` if `text` is empty. +// Otherwise, it returns a new error with the format "text: original_error". +func WrapIfError(text string, err error) error { + if err == nil { + return nil + } + + if text == "" { + return err + } + + return fmt.Errorf("%s: %w", text, err) +} + // MapKeys gets the keys of the given map and saves them into a slice. There is not to expect a particular -// order of the elements of that slice. A nil map will produce a nil result, an empty map produces an empty +// order of the elements in that slice. A nil map will produce a nil result, an empty map produces an empty // slice. -func MapKeys[T comparable, S any](m map[T]S) []T { - if m == nil { +func MapKeys[T comparable, S any](source map[T]S) []T { + if source == nil { return nil } - if len(m) == 0 { + if len(source) == 0 { return make([]T, 0) } - result := make([]T, 0, len(m)) + result := make([]T, 0, len(source)) - for k := range m { + for k := range source { result = append(result, k) } return result } -// GetOrCreateID generates a new uuid, if the given id is empty, otherwise the given id is returned. +// GetOrCreateID generates a new uuid if the given id is empty, otherwise the given id is returned. func GetOrCreateID(id string) string { if len(id) > 0 { return id @@ -60,14 +77,14 @@ func GetOrCreateID(id string) string { newID := "n/a" - if newUuid, err := uuid.NewRandom(); err == nil { - newID = newUuid.String() + if newUUID, err := uuid.NewRandom(); err == nil { + newID = newUUID.String() } return newID } -// WriteState sets the specified HTTP response code and writes the code specific text as body. +// WriteState sets the specified HTTP response code and writes the code-specific text as body. // If an error occurs on writing to the client, it is logged to the specified logging instance. // It is intended to give error feedback to clients. func WriteState(w http.ResponseWriter, log *slog.Logger, httpState int) { @@ -87,19 +104,21 @@ func WriteState(w http.ResponseWriter, log *slog.Logger, httpState int) { // IntroCheck is used to facilitate the introductory check in each handler for the basic requirements. // It manages the corresponding logging operations and can be used as follows: // -// if !util.IntroCheck(h, w, r) { +// if !helper.IntroCheck(h, w, r) { // return // } func IntroCheck(h defs.MWBaser, w http.ResponseWriter, r *http.Request) bool { if reflect.ValueOf(h).IsNil() { slog.Error("handler nil") WriteState(w, slog.Default(), http.StatusInternalServerError) + return false } if r == nil { slog.Debug("request nil") WriteState(w, h.GetMWBase().Log(), http.StatusBadRequest) + return false } diff --git a/helper/helper_internal_test.go b/helper/helper_internal_test.go new file mode 100644 index 0000000..fb94d0a --- /dev/null +++ b/helper/helper_internal_test.go @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package helper + +// The following variables are used for internal testing and are not visible to normal library users. +var TexitFunc = &exitFunc //nolint:gochecknoglobals diff --git a/helper/helper_test.go b/helper/helper_test.go new file mode 100644 index 0000000..4844640 --- /dev/null +++ b/helper/helper_test.go @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: 2025 The midgard contributors. +// SPDX-License-Identifier: MPL-2.0 + +package helper_test + +import ( + "bytes" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "regexp" + "slices" + "strings" + "testing" + + "github.com/AlphaOne1/midgard/defs" + "github.com/AlphaOne1/midgard/helper" +) + +func TestMustGood(t *testing.T) { + t.Parallel() + + got := helper.Must("pass", nil) + + if got != "pass" { + t.Errorf(`expected "pass" but got %v`, got) + } +} + +//nolint:paralleltest // manipulating global exit function +func TestMustBad(t *testing.T) { + outbuf := bytes.Buffer{} + *(helper.TexitFunc) = func(_ int) {} + defer func() { *(helper.TexitFunc) = os.Exit }() + + slog.SetDefault(slog.New(slog.NewTextHandler(&outbuf, &slog.HandlerOptions{}))) + + got := helper.Must("nopass", errors.New("testerror")) + + if got != "nopass" { + t.Errorf("got %v but wanted `nopass`", got) + } + + outputMatch := regexp.MustCompile(`^time=[^ ]+ level=ERROR msg="must-condition not met" error=testerror\n`) + + if !outputMatch.Match(outbuf.Bytes()) { + t.Errorf("output does not match format %v, got %v", + outputMatch.String(), + outbuf.String()) + } +} + +func TestWrapIfError(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + text string + wantErr bool + wantErrMsg string + }{ + { // 0 + text: "wrap this", + err: errors.New("original error"), + wantErr: true, + wantErrMsg: "wrap this: original error", + }, + { // 1 + text: "wrap that: %w", + err: errors.New("original error"), + wantErr: true, + wantErrMsg: "wrap that: %w: original error", + }, + { // 2 + text: "wrap this", + err: nil, + wantErr: false, + }, + { // 3 + text: "", + err: errors.New("error case"), + wantErr: true, + wantErrMsg: "error case", + }, + { // 4 + text: "", + err: nil, + wantErr: false, + }, + } + + for testIndex, test := range tests { + t.Run(fmt.Sprintf("WrapError-%d", testIndex), func(t *testing.T) { + t.Parallel() + + got := helper.WrapIfError(test.text, test.err) + + if (got != nil) != test.wantErr { + t.Errorf(`got error "%v", but wanted "%v"`, got, test.wantErr) + } + + if test.wantErr && got.Error() != test.wantErrMsg { + t.Errorf(`got error "%v" but wanted "%v"`, got.Error(), test.wantErrMsg) + } + }) + } +} + +func TestGetOrCreateID(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + wantNew bool + }{ + { + in: "", + wantNew: true, + }, + { + in: "nonsense", + wantNew: false, + }, + } + + for k, v := range tests { + got := helper.GetOrCreateID(v.in) + + if v.wantNew && got == v.in { + t.Errorf("%v: wanted new UUID but got old one", k) + } + + if !v.wantNew && got != v.in { + t.Errorf("%v: wanted old UUID but got new one", k) + } + } +} + +func TestDummyHandler(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + helper.DummyHandler(rec, req) + + if rec.Body.String() != "dummy" { + t.Errorf("wanted Dummy but got %v", rec.Body.String()) + } +} + +func TestWriteState(t *testing.T) { + t.Parallel() + + tests := []struct { + state int + }{ + { + state: http.StatusOK, + }, + { + state: http.StatusBadRequest, + }, + { + state: http.StatusAccepted, + }, + } + + for k, v := range tests { + t.Run(fmt.Sprintf("TestWriteState-%d", k), func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + helper.WriteState(rec, slog.Default(), v.state) + + if rec.Body.String() != http.StatusText(v.state) { + t.Errorf("wanted %v but got %v", http.StatusText(v.state), rec.Body.String()) + } + + if ct := rec.Result().Header["Content-Type"]; len(ct) == 0 || ct[0] != "text/plain; charset=utf-8" { + t.Errorf("content type not set correctly, set to %v", ct) + } + + if cto := rec.Result().Header["X-Content-Type-Options"]; len(cto) == 0 || cto[0] != "nosniff" { + t.Errorf("content type options not set correctly, set to %v", cto) + } + }) + } +} + +type MWTest struct { + defs.MWBase +} + +func (h *MWTest) GetMWBase() *defs.MWBase { + return &h.MWBase +} + +func TestIntroCheck(t *testing.T) { + t.Parallel() + + tests := []struct { + h *MWTest + req *http.Request + want bool + }{ + { + h: &MWTest{}, + req: helper.Must(http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)), + want: true, + }, + { + h: nil, + req: helper.Must(http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)), + want: false, + }, + { + h: &MWTest{}, + req: nil, + want: false, + }, + { + h: nil, + req: nil, + want: false, + }, + } + + for k, v := range tests { + t.Run(fmt.Sprintf("TestIntroCheck-%d", k), func(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + got := helper.IntroCheck(v.h, rec, v.req) + + if got != v.want { + t.Errorf("got %v but wanted %v", got, v.want) + } + }) + } +} + +func TestMapKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + in map[string]int + want []string + }{ + { + in: map[string]int{"1": 1, "2": 2, "3": 3}, + want: []string{"1", "2", "3"}, + }, + { + in: nil, + want: nil, + }, + { + in: map[string]int{}, + want: []string{}, + }, + } + + for k, test := range tests { + t.Run(fmt.Sprintf("TestMapKeys-%d", k), func(t *testing.T) { + t.Parallel() + + got := helper.MapKeys(test.in) + slices.Sort(got) + slices.Sort(test.want) + + if test.in == nil && got != nil { + t.Errorf("got non-nil result %v but wanted nil", got) + } + + if len(test.in) == 0 && test.in != nil && (len(got) != 0 || got == nil) { + t.Errorf("got %v but wanted zero length non-nil result", got) + } + + if len(test.in) > 0 && strings.Join(got, ",") != strings.Join(test.want, ",") { + t.Errorf("got %v but wanted %v", got, test.want) + } + }) + } +} diff --git a/midgard.go b/midgard.go index 4c71b55..a60f02c 100644 --- a/midgard.go +++ b/midgard.go @@ -1,6 +1,7 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 +// Package midgard provides a set of utilities for building HTTP middleware. package midgard import ( diff --git a/midgard_logo.svg b/midgard_logo.svg index 26b71b8..1fe2ac8 100644 --- a/midgard_logo.svg +++ b/midgard_logo.svg @@ -1,4 +1,7 @@ + diff --git a/midgard_test.go b/midgard_test.go index 90d7ea3..d3aef08 100644 --- a/midgard_test.go +++ b/midgard_test.go @@ -1,27 +1,30 @@ -// Copyright the midgard contributors. +// SPDX-FileCopyrightText: 2025 The midgard contributors. // SPDX-License-Identifier: MPL-2.0 -package midgard +package midgard_test import ( "net/http" "net/http/httptest" "testing" + "github.com/AlphaOne1/midgard" "github.com/AlphaOne1/midgard/defs" "github.com/AlphaOne1/midgard/handler/correlation" - "github.com/AlphaOne1/midgard/handler/method_filter" - "github.com/AlphaOne1/midgard/util" + "github.com/AlphaOne1/midgard/handler/methodfilter" + "github.com/AlphaOne1/midgard/helper" ) func TestStackMiddleware(t *testing.T) { - newMWHandler := StackMiddlewareHandler( + t.Parallel() + + newMWHandler := midgard.StackMiddlewareHandler( []defs.Middleware{ - util.Must(method_filter.New( - method_filter.WithMethods([]string{http.MethodGet}))), - util.Must(correlation.New()), + helper.Must(methodfilter.New( + methodfilter.WithMethods([]string{http.MethodGet}))), + helper.Must(correlation.New()), }, - http.HandlerFunc(util.DummyHandler), + http.HandlerFunc(helper.DummyHandler), ) _ = newMWHandler @@ -54,9 +57,11 @@ func TestStackMiddleware(t *testing.T) { } func TestEmptyMiddlewareHandler(t *testing.T) { - newMWHandler := StackMiddlewareHandler( + t.Parallel() + + newMWHandler := midgard.StackMiddlewareHandler( []defs.Middleware{}, - http.HandlerFunc(util.DummyHandler)) + http.HandlerFunc(helper.DummyHandler)) req := httptest.NewRequest(http.MethodGet, "/", nil) res := httptest.NewRecorder() @@ -81,7 +86,9 @@ func TestEmptyMiddlewareHandler(t *testing.T) { } func TestEmptyMiddleware(t *testing.T) { - got := StackMiddleware([]defs.Middleware{}) + t.Parallel() + + got := midgard.StackMiddleware([]defs.Middleware{}) if got != nil { t.Errorf("expected nil on empty middleware stack") diff --git a/util/util_test.go b/util/util_test.go deleted file mode 100644 index e4ed47c..0000000 --- a/util/util_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright the midgard contributors. -// SPDX-License-Identifier: MPL-2.0 - -package util - -import ( - "bytes" - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "regexp" - "slices" - "strings" - "testing" - - "github.com/AlphaOne1/midgard/defs" -) - -func TestMustGood(t *testing.T) { - got := Must("pass", nil) - - if got != "pass" { - t.Errorf(`expected "pass" but got %v`, got) - } -} - -func TestMustBad(t *testing.T) { - outbuf := bytes.Buffer{} - exitFunc = func(_ int) {} - defer func() { exitFunc = os.Exit }() - - slog.SetDefault(slog.New(slog.NewTextHandler(&outbuf, &slog.HandlerOptions{}))) - - got := Must("nopass", errors.New("testerror")) - - if got != "nopass" { - t.Errorf("got %v but wanted `nopass`", got) - } - - outputMatch := regexp.MustCompile(`^time=[^ ]+ level=ERROR msg="must-condition not met" error=testerror\n`) - - if !outputMatch.Match(outbuf.Bytes()) { - t.Errorf("output does not match format %v, got %v", - outputMatch.String(), - outbuf.String()) - } -} - -func TestGetOrCreateID(t *testing.T) { - tests := []struct { - in string - wantNew bool - }{ - { - in: "", - wantNew: true, - }, - { - in: "nonsense", - wantNew: false, - }, - } - - for k, v := range tests { - got := GetOrCreateID(v.in) - - if v.wantNew == true && got == v.in { - t.Errorf("%v: wanted new UUID but got old one", k) - } - - if !v.wantNew == true && got != v.in { - t.Errorf("%v: wanted old UUID but got new one", k) - } - } -} - -func TestDummyHandler(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - - DummyHandler(rec, req) - - if rec.Body.String() != "dummy" { - t.Errorf("wanted Dummy but got %v", rec.Body.String()) - } -} - -func TestWriteState(t *testing.T) { - tests := []struct { - state int - }{ - { - state: http.StatusOK, - }, - { - state: http.StatusBadRequest, - }, - { - state: http.StatusAccepted, - }, - } - - for k, v := range tests { - rec := httptest.NewRecorder() - WriteState(rec, slog.Default(), v.state) - - if rec.Body.String() != http.StatusText(v.state) { - t.Errorf("%v: wanted %v but got %v", k, http.StatusText(v.state), rec.Body.String()) - } - - if ct := rec.Result().Header["Content-Type"]; len(ct) == 0 || ct[0] != "text/plain; charset=utf-8" { - t.Errorf("%v: content type not set correctly, set to %v", k, ct) - } - - if cto := rec.Result().Header["X-Content-Type-Options"]; len(cto) == 0 || cto[0] != "nosniff" { - t.Errorf("%v: content type options not set correctly, set to %v", k, cto) - } - } -} - -type MWTest struct { - defs.MWBase -} - -func (h *MWTest) GetMWBase() *defs.MWBase { - return &h.MWBase -} - -func TestIntroCheck(t *testing.T) { - tests := []struct { - h *MWTest - req *http.Request - want bool - }{ - { - h: &MWTest{}, - req: Must(http.NewRequest(http.MethodGet, "/", nil)), - want: true, - }, - { - h: nil, - req: Must(http.NewRequest(http.MethodGet, "/", nil)), - want: false, - }, - { - h: &MWTest{}, - req: nil, - want: false, - }, - { - h: nil, - req: nil, - want: false, - }, - } - - rec := httptest.NewRecorder() - - for k, v := range tests { - got := IntroCheck(v.h, rec, v.req) - - if got != v.want { - t.Errorf("%v: got %v but wanted %v", k, got, v.want) - } - } -} - -func TestMapKeys(t *testing.T) { - tests := []struct { - in map[string]int - want []string - }{ - { - in: map[string]int{"1": 1, "2": 2, "3": 3}, - want: []string{"1", "2", "3"}, - }, - { - in: nil, - want: nil, - }, - { - in: map[string]int{}, - want: []string{}, - }, - } - - for k, v := range tests { - got := MapKeys(v.in) - slices.Sort(got) - slices.Sort(v.want) - - if v.in == nil && got != nil { - t.Errorf("%v: got non-nil result %v but wanted nil", k, got) - } - - if len(v.in) == 0 && v.in != nil && (len(got) != 0 || got == nil) { - t.Errorf("%v: got %v but wanted zero length non-nil result", k, got) - } - - if len(v.in) > 0 && strings.Join(got, ",") != strings.Join(v.want, ",") { - t.Errorf("%v: got %v but wanted %v", k, got, v.want) - } - } -}