From cc476c62067bd2ab58f03e6cef59a78b7dd659d2 Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi Date: Tue, 8 Sep 2026 13:25:59 +0330 Subject: [PATCH 1/5] =?UTF-8?q?feat(ci):=20Phase=201=20=E2=80=94=20release?= =?UTF-8?q?=20safety=20and=20stable-v1=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolate root auto-versioning from v2 commit history so v2 feat!/refactor! commits cannot accidentally trigger a root major bump. Add a manual release-v1.yml workflow modeled on release-v2.yml with test/vet/build gates and duplicate-tag protection. Strengthen CI with a root-only consumer build job verifying the unsuffixed import path on Go 1.27. Add MIGRATION.md for v0.28.1 to v1.x and correct version-status text in README.md and v2/README.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/auto-version.yml | 52 ++++--- .github/workflows/ci.yml | 55 +++++++ .github/workflows/release-v1.yml | 230 +++++++++++++++++++++++++++++ MIGRATION.md | 129 ++++++++++++++++ README.md | 20 +++ go.work.sum | 1 + v2/README.md | 16 +- 7 files changed, 478 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/release-v1.yml create mode 100644 MIGRATION.md diff --git a/.github/workflows/auto-version.yml b/.github/workflows/auto-version.yml index c9e845d..b5ecf08 100644 --- a/.github/workflows/auto-version.yml +++ b/.github/workflows/auto-version.yml @@ -73,20 +73,35 @@ jobs: if: steps.check_v2_only.outputs.v2_only != 'true' id: version_bump run: | - # Get commit messages since last tag + # Get commit messages since last tag, scoped to root-module + # files only. This prevents v2-only commits (which may contain + # feat!/refactor! subjects) from accidentally triggering a root + # major version bump. LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" - COMMITS=$(git log --pretty=format:"%s" ${LATEST_TAG}..HEAD) + COMMITS=$(git log --pretty=format:"%s" ${LATEST_TAG}..HEAD -- . ":(exclude)v2/") - echo "Commits since $LATEST_TAG:" + echo "Root-module commits since $LATEST_TAG:" echo "$COMMITS" + if [ -z "$COMMITS" ]; then + echo "No root-module commits since $LATEST_TAG — skipping version bump" + echo "major_bump=false" >> $GITHUB_OUTPUT + echo "minor_bump=false" >> $GITHUB_OUTPUT + echo "patch_bump=false" >> $GITHUB_OUTPUT + echo "no_commits=true" >> $GITHUB_OUTPUT + exit 0 + fi + # Determine version bump based on conventional commits MAJOR_BUMP=false MINOR_BUMP=false PATCH_BUMP=false - # Check for breaking changes - if echo "$COMMITS" | grep -qiE "(breaking|!|BREAKING)"; then + # Check for breaking changes using the conventional-commit `!` + # indicator (e.g. "feat(scope)!: ..." or "BREAKING CHANGE: ..."). + # The bare `!` match is intentionally scoped to the conventional- + # commit prefix, not any `!` anywhere in the message body. + if echo "$COMMITS" | grep -qiE '^[a-z]+(\(.+\))?!:' || echo "$COMMITS" | grep -qiE "^BREAKING[ -]CHANGE"; then MAJOR_BUMP=true echo "Major version bump detected (breaking changes)" # Check for new features @@ -102,6 +117,7 @@ jobs: PATCH_BUMP=true echo "Default patch version bump" fi + echo "no_commits=false" >> $GITHUB_OUTPUT echo "major_bump=$MAJOR_BUMP" >> $GITHUB_OUTPUT echo "minor_bump=$MINOR_BUMP" >> $GITHUB_OUTPUT @@ -134,7 +150,7 @@ jobs: echo "New version: $NEW_VERSION" - name: Generate release notes - if: steps.check_v2_only.outputs.v2_only != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag + if: steps.check_v2_only.outputs.v2_only != 'true' && steps.version_bump.outputs.no_commits != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag id: release_notes run: | LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" @@ -142,34 +158,34 @@ jobs: echo "body<> "$GITHUB_OUTPUT" - name: Create and push tag - if: steps.check_v2_only.outputs.v2_only != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag + if: steps.check_v2_only.outputs.v2_only != 'true' && steps.version_bump.outputs.no_commits != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag run: | NEW_VERSION="${{ steps.new_version.outputs.new_version }}" @@ -182,7 +198,7 @@ jobs: echo "Created and pushed tag: $NEW_VERSION" - name: Create Release - if: steps.check_v2_only.outputs.v2_only != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag + if: steps.check_v2_only.outputs.v2_only != 'true' && steps.version_bump.outputs.no_commits != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.new_version.outputs.new_version }} @@ -194,7 +210,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Update go.mod version - if: steps.check_v2_only.outputs.v2_only != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag + if: steps.check_v2_only.outputs.v2_only != 'true' && steps.version_bump.outputs.no_commits != 'true' && steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag run: | NEW_VERSION="${{ steps.new_version.outputs.new_version }}" @@ -213,4 +229,4 @@ jobs: echo "- **Previous version:** ${{ steps.get_tag.outputs.latest_tag }}" >> $GITHUB_STEP_SUMMARY echo "- **New version:** ${{ steps.new_version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY echo "- **Bump type:** ${{ steps.version_bump.outputs.major_bump == 'true' && 'Major' || steps.version_bump.outputs.minor_bump == 'true' && 'Minor' || 'Patch' }}" >> $GITHUB_STEP_SUMMARY - echo "- **Tag created:** ${{ steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag && 'Yes' || 'No (no changes)' }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tag created:** ${{ steps.version_bump.outputs.no_commits == 'true' && 'No (no root-module commits)' || steps.new_version.outputs.new_version != steps.get_tag.outputs.latest_tag && 'Yes' || 'No (no changes)' }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 089abce..35f3fc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,3 +182,58 @@ jobs: - name: Build with vendor mode working-directory: /tmp/consumer-test run: go build -mod=vendor ./... + + # Verifies that a downstream consumer can build against the root + # module using the real unsuffixed import path + # (github.com/hmmftg/requestCore) with Go 1.27, without v2 or the + # workspace. This guards the stable-v1 import-path contract. + root-consumer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Force standalone root module + run: | + if [ -f go.work ]; then rm go.work; fi + if [ -f go.work.sum ]; then rm go.work.sum; fi + - name: Tidy root module + run: go mod tidy + - name: Create root-only consumer module + run: | + mkdir -p /tmp/root-consumer + cd /tmp/root-consumer + cat > go.mod <<'GOMOD' + module root-consumer + + go 1.27.0 + + require github.com/hmmftg/requestCore v0.0.0 + GOMOD + echo "replace github.com/hmmftg/requestCore => $GITHUB_WORKSPACE" >> go.mod + - name: Create consumer main.go + run: | + cd /tmp/root-consumer + cat > main.go <<'GOFILE' + package main + + import ( + _ "github.com/hmmftg/requestCore/webFramework" + _ "github.com/hmmftg/requestCore/handlers" + _ "github.com/hmmftg/requestCore/response" + _ "github.com/hmmftg/requestCore/libRequest" + ) + + func main() {} + GOFILE + - name: Tidy consumer + working-directory: /tmp/root-consumer + run: go mod tidy + - name: Build consumer + working-directory: /tmp/root-consumer + run: go build ./... + - name: Vet consumer + working-directory: /tmp/root-consumer + run: go vet ./... diff --git a/.github/workflows/release-v1.yml b/.github/workflows/release-v1.yml new file mode 100644 index 0000000..680f751 --- /dev/null +++ b/.github/workflows/release-v1.yml @@ -0,0 +1,230 @@ +name: Release v1 + +on: + # Manual trigger only — prevents accidental automatic tagging. + # The caller selects the bump type and whether it is a prerelease. + # This workflow releases the root module (github.com/hmmftg/requestCore) + # and is independent of the v2 release workflow. + workflow_dispatch: + inputs: + bump_type: + description: "Version bump type" + required: true + type: choice + default: prerelease + options: + - prerelease + - patch + - minor + - major + prerelease_label: + description: "Prerelease label (alpha, beta, rc) — used only when bump_type=prerelease" + required: false + type: string + default: "rc" + draft: + description: "Create as draft release" + required: false + type: boolean + default: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # Force standalone module mode: remove go.work so the root + # module resolves dependencies from its own go.mod, not the + # workspace that includes v2. + - name: Force standalone root module + run: | + if [ -f go.work ]; then rm go.work; fi + if [ -f go.work.sum ]; then rm go.work.sum; fi + + - name: Tidy root module + run: go mod tidy + + - name: Validate root module + run: | + if ! grep -q "^module github.com/hmmftg/requestCore$" go.mod; then + echo "ERROR: go.mod does not have correct module path" + exit 1 + fi + + - name: Test root module + run: go test -race ./... + + - name: Vet root module + run: go vet ./... + + - name: Build root module + run: go build ./... + + - name: Build examples + run: go build ./examples/... + + - name: Get latest root tag + id: get_tag + run: | + # Get the latest root module tag (v0.x.x or v1.x.x), + # excluding v2/ tags. + LATEST_TAG=$(git tag --list "v[0-9]*" --sort=-version:refname | grep -v "^v2/" | head -1 || echo "") + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG="v0.28.1" + fi + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "Latest root tag: $LATEST_TAG" + + - name: Calculate new version + id: new_version + run: | + LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" + BUMP_TYPE="${{ github.event.inputs.bump_type }}" + PRE_LABEL="${{ github.event.inputs.prerelease_label }}" + + # Extract version from tag (format: v0.x.y, v1.x.y, or v1.x.y-rc.N) + VERSION=${LATEST_TAG#v} + + # Split base version and prerelease suffix + BASE_VERSION="${VERSION%%-*}" + EXISTING_PRE="" + if [[ "$VERSION" == *"-"* ]]; then + EXISTING_PRE="-${VERSION#*-}" + fi + + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" + + echo "Current: v${MAJOR}.${MINOR}.${PATCH}${EXISTING_PRE}" + echo "Bump type: ${BUMP_TYPE}" + + if [ "$BUMP_TYPE" = "major" ]; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + IS_PRERELEASE=false + elif [ "$BUMP_TYPE" = "minor" ]; then + MINOR=$((MINOR + 1)) + PATCH=0 + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + IS_PRERELEASE=false + elif [ "$BUMP_TYPE" = "patch" ]; then + PATCH=$((PATCH + 1)) + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + IS_PRERELEASE=false + elif [ "$BUMP_TYPE" = "prerelease" ]; then + # Increment prerelease number or start a new prerelease series. + # If the existing version has a prerelease suffix with the same + # label, increment the number. Otherwise, bump the patch and + # start at .0 with the new label. + if [[ "$EXISTING_PRE" == "-${PRE_LABEL}."* ]]; then + # Same label: increment the prerelease number + PRE_NUM=$(echo "$EXISTING_PRE" | sed "s/-${PRE_LABEL}\.//") + PRE_NUM=$((PRE_NUM + 1)) + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}-${PRE_LABEL}.${PRE_NUM}" + elif [[ "$EXISTING_PRE" == "-${PRE_LABEL}" ]]; then + # Same label without number: start at .1 + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}-${PRE_LABEL}.1" + else + # Different label or no existing prerelease: bump patch + # and start new prerelease series at .0 + PATCH=$((PATCH + 1)) + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}-${PRE_LABEL}.0" + fi + IS_PRERELEASE=true + else + echo "ERROR: unknown bump type: $BUMP_TYPE" + exit 1 + fi + + TAG="${NEW_VERSION}" + echo "new_tag=$TAG" >> $GITHUB_OUTPUT + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + echo "New tag: $TAG" + echo "Is prerelease: $IS_PRERELEASE" + + - name: Check tag does not already exist + run: | + TAG="${{ steps.new_version.outputs.new_tag }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "ERROR: tag $TAG already exists" + exit 1 + fi + echo "Tag $TAG is available" + + - name: Generate release notes + id: release_notes + run: | + LATEST_TAG="${{ steps.get_tag.outputs.latest_tag }}" + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create and push tag + run: | + TAG="${{ steps.new_version.outputs.new_tag }}" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "Created and pushed tag: $TAG" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.new_version.outputs.new_tag }} + name: Release ${{ steps.new_version.outputs.new_tag }} + body: ${{ steps.release_notes.outputs.body }} + draft: ${{ github.event.inputs.draft == 'true' }} + prerelease: ${{ steps.new_version.outputs.is_prerelease == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## v1 Release Summary" >> $GITHUB_STEP_SUMMARY + echo "- **Previous tag:** ${{ steps.get_tag.outputs.latest_tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **New tag:** ${{ steps.new_version.outputs.new_tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Bump type:** ${{ github.event.inputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "- **Prerelease:** ${{ steps.new_version.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY + echo "- **Draft:** ${{ github.event.inputs.draft }}" >> $GITHUB_STEP_SUMMARY diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..5889c1a --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,129 @@ +# Migration Guide: v0.28.1 → v1.x + +This guide covers upgrading from the last `v0.x` release (`v0.28.1`) to +the stable `v1.x` line of the root module +(`github.com/hmmftg/requestCore`). + +## Summary + +The v1.0 release stabilizes the root module's import path and public API. +The upgrade is designed to be **non-breaking** for existing consumers: +default behavior, response envelopes, and success statuses remain +unchanged. All new HTTP standards features (RFC 9457 Problem Details, +OAuth no-store headers, conditional requests, Link headers, Retry-After, +idempotency contracts) are **opt-in** and do not alter existing code paths. + +## Prerequisites + +- **Go 1.27+** is required. The `go.mod` directive was bumped from + `1.25.5` (v0.28.1) to `1.27.0` in v1.0. +- If you are on an older Go toolchain, update before upgrading + `requestCore`. + +## Import Path + +The import path is **unchanged**: + +```go +import "github.com/hmmftg/requestCore" +``` + +Go major version 1 uses the unsuffixed module path. No `/v1` suffix is +needed and no directory rename is required. + +## What Changed Since v0.28.1 + +The root-module delta from `v0.28.1` to `v1.0.0` is limited to +non-breaking infrastructure: + +| Area | Change | Consumer impact | +|---|---|---| +| `go.mod` | Go directive `1.25.5` → `1.27.0` | Requires Go 1.27+ toolchain | +| `go.work` | Added workspace including `./v2` | None (workspace is dev-only; consumers use `go.mod`) | +| CI workflows | Added v2 CI, release workflows, lint, architecture checks | None | +| `README.md` | Updated documentation | None | +| `.golangci.yml` | Lint configuration | None | + +**No root Go source files changed** between `v0.28.1` and the v1.0 +baseline. Existing handler, response, request, query, tracing, and +adapter code is identical. + +## Behavioral Compatibility + +All default behaviors are preserved: + +- **Success status:** `response.OK` continues to use HTTP 200. +- **Response envelope:** The legacy `WsResponse` / `ErrorResponse` + envelope is unchanged by default. +- **Error format:** Errors continue to use the existing custom envelope. +- **Request headers:** `Request-Id`, `Program-Id`, `Module-Id`, + `Method-Id`, and `User-Id` headers are unchanged. +- **Observability:** `webFramework.AddLog` remains on every external-call + and transaction path with the same keys and severity semantics. + +## New Opt-In Features (v1.x) + +The following standards-oriented features are added as **opt-in** APIs. +They do not change default behavior: + +- **RFC 9457 Problem Details** — opt-in problem responder + (`response/problem.go`) that maps errors to `application/problem+json` + while delegating successes to the legacy handler. +- **Configurable success status** — `HandlerParameters.SuccessStatus` + allows 201, 202, 204, etc. Default remains 200. +- **OAuth no-store headers** — `httpsemantics` helper for + `Cache-Control: no-store` + `Pragma: no-cache` on token responses. +- **RFC 6750 Bearer challenges** — `httpsemantics` helper for + `WWW-Authenticate` on 401 resource-server responses. +- **Conditional requests** — `httpsemantics` helpers for ETag + parsing/comparison and `If-Match`/`If-None-Match`/`If-Modified-Since`/ + `If-Unmodified-Since` evaluation. +- **RFC 8288 Link headers** — `httpsemantics` helper for pagination and + relation link serialization. +- **Retry-After** — `httpsemantics` parser/formatter for delta-seconds + and HTTP-date forms; opt-in `HonorRetryAfter` in `RetryPolicy`. +- **Idempotency contracts** — `idempotency` package with store interfaces + and in-memory test store. Application owns persistence and replay + policy. + +## How to Upgrade + +1. **Update Go** to 1.27+ if you haven't already. +2. **Update the dependency:** + + ```bash + go get github.com/hmmftg/requestCore@v1.0.0 + go mod tidy + ``` + +3. **Run your existing tests.** They should pass without changes. +4. **(Optional)** Adopt opt-in features as needed. See the + `httpsemantics`, `idempotency`, and `response/problem.go` package + documentation. + +## What Did NOT Change + +- No public types, functions, or methods were removed or renamed. +- No method signatures changed. +- No required struct fields were added to existing types. +- The `ResponseHandler` interface is unchanged. +- The `RequestParser` interface is unchanged. +- The `RetryPolicy` defaults (fixed backoff, no Retry-After honoring) + are unchanged. + +## v2 Module + +The nested `v2/` module (`github.com/hmmftg/requestCore/v2`) is a +**separate module** with its own `go.mod`, tags (`v2/v2.x.y`), and release +workflow. It is independent of the root v1 release line. See +[v2/MIGRATION.md](v2/MIGRATION.md) for v1-to-v2 migration guidance. + +## Questions + +If you encounter issues during upgrade, verify: + +1. Your Go version is 1.27+. +2. Your `go.mod` does not have a `replace` directive pointing to an old + path. +3. You are not importing internal packages that moved between v0.x and + v1.x (none are expected, but check if you imported `internal/` paths). diff --git a/README.md b/README.md index aabc5dc..ed28d76 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,25 @@ The repository is centered around a thin root façade and multiple focused subpa --- +## Release Lines + +This repository contains **two independent Go modules** with separate +release streams: + +| Module | Import path | Tags | Status | +|---|---|---|---| +| Root (v1) | `github.com/hmmftg/requestCore` | `v0.x.y`, `v1.x.y` | Stable (v1.0 line) | +| v2 | `github.com/hmmftg/requestCore/v2` | `v2/v2.x.y` | Alpha prerelease | + +- The root module is the stable v1 line. Upgrade from `v0.28.1` using + [MIGRATION.md](MIGRATION.md). +- The v2 module is a separate module under `v2/` with its own `go.mod`, + tags, and [release workflow](.github/workflows/release-v2.yml). See + [v2/README.md](v2/README.md) and [v2/MIGRATION.md](v2/MIGRATION.md). +- v2-only commits do not trigger root module versioning. + +--- + ## Installation ```bash @@ -476,6 +495,7 @@ requestCore/ Additional documentation included in the repository: +- `MIGRATION.md` — v0.28.1 → v1.x upgrade guide - `OPENTELEMETRY_INTEGRATION.md` - `NETHTTP_IMPLEMENTATION_COMPLETE.md` - `DYNAMIC_HEADERS_GUIDE.md` diff --git a/go.work.sum b/go.work.sum index 7845187..1459277 100644 --- a/go.work.sum +++ b/go.work.sum @@ -44,6 +44,7 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/v2/README.md b/v2/README.md index 9ab69ff..ce0365f 100644 --- a/v2/README.md +++ b/v2/README.md @@ -6,13 +6,15 @@ A **generics-first**, framework-agnostic HTTP application toolkit for Go. Requires **Go 1.27+**. -> **Status:** v2 has **no released tags** and is under active development. -> The API described here is the canonical kernel API and will remain the -> basis for the first stable v2 release, but minor refinements may still -> occur before a tag is cut. See [MIGRATION.md](MIGRATION.md) for the -> migration guide and the Tranche 5 lifecycle features (persistence, -> tracing, initializers, finalizers, recovery callbacks, ID parsers). -> v1 (the root module) remains supported and stable. +> **Status:** v2 is in **alpha** prerelease (`v2/v2.0.0-alpha.N` tags) +> and under active development. The API described here is the canonical +> kernel API and will remain the basis for the first stable v2 release, +> but minor refinements may still occur before a stable tag is cut. See +> [MIGRATION.md](MIGRATION.md) for the migration guide and the Tranche 5 +> lifecycle features (persistence, tracing, initializers, finalizers, +> recovery callbacks, ID parsers). v1 (the root module) remains +> supported and stable; see the root [MIGRATION.md](../MIGRATION.md) for +> the v0.28.1 → v1.x upgrade guide. v2 builds on the root [requestCore](../README.md) module with a canonical, stdlib-first kernel: typed endpoints, a framework-neutral routing contract, From cf51bcc5a3f8ee097518c73bbf6ca05e52c07ea2 Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi Date: Tue, 8 Sep 2026 14:02:26 +0330 Subject: [PATCH 2/5] =?UTF-8?q?feat(response):=20Phase=202=20=E2=80=94=20R?= =?UTF-8?q?FC=209457,=20status-aware=20success,=20OAuth/Bearer=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in RFC 9457 Problem Details model and mapper to v1 response package, mapping libError.ErrorData and response.ErrorData to sanitized problem fields without exposing internal messages or causes. Extend WebHanlder with OKWithStatus/OKWithStatusAndHeaders for configurable 2xx success statuses with 204/205 body suppression. Add StatusAwareResponder optional interface without widening ResponseHandler. Extend HandlerParameters with SuccessStatus and SuccessHeaders, applied through BaseHandler success paths. Add httpsemantics package in both v1 and v2 with OAuth no-store (Cache-Control/Pragma) and RFC 6750 Bearer challenge helpers with header-injection-safe escaping. Add v2 regression tests for 201+Location, 204 no body, HEAD suppression, and mapper sanitization. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- go.work.sum | 1 + handlers/baseHandler.go | 62 +++++- httpsemantics/auth.go | 187 ++++++++++++++++++ httpsemantics/auth_test.go | 171 ++++++++++++++++ response/model.go | 12 ++ response/problem.go | 200 +++++++++++++++++++ response/problem_mapper.go | 274 ++++++++++++++++++++++++++ response/problem_test.go | 273 +++++++++++++++++++++++++ response/webHandler.go | 47 ++++- v2/httpsemantics/auth.go | 129 ++++++++++++ v2/httpsemantics/auth_test.go | 74 +++++++ v2/response/regression_phase2_test.go | 142 +++++++++++++ 12 files changed, 1555 insertions(+), 17 deletions(-) create mode 100644 httpsemantics/auth.go create mode 100644 httpsemantics/auth_test.go create mode 100644 response/problem.go create mode 100644 response/problem_mapper.go create mode 100644 response/problem_test.go create mode 100644 v2/httpsemantics/auth.go create mode 100644 v2/httpsemantics/auth_test.go create mode 100644 v2/response/regression_phase2_test.go diff --git a/go.work.sum b/go.work.sum index 7845187..1459277 100644 --- a/go.work.sum +++ b/go.work.sum @@ -44,6 +44,7 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/handlers/baseHandler.go b/handlers/baseHandler.go index a82739e..24f7837 100644 --- a/handlers/baseHandler.go +++ b/handlers/baseHandler.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "net/http" "time" "go.opentelemetry.io/otel/attribute" @@ -34,6 +35,15 @@ type HandlerParameters[Req, Resp any] struct { // Tracing parameters EnableTracing bool TracingSpanName string + // SuccessStatus is the HTTP status code for successful responses. + // Defaults to 0, which means use the responder's default (200). + // Set to 201, 204, etc. for endpoints that need non-200 success + // statuses. This is opt-in and does not change default behavior. + SuccessStatus int + // SuccessHeaders are static response headers applied to successful + // responses. Applied before the response body is written. This is + // opt-in and does not change default behavior. + SuccessHeaders map[string]string } // HandlerInterface is the interface that request handlers must implement. @@ -142,20 +152,52 @@ func respondError[Req, Resp any](core requestCore.RequestCoreInterface, trx *Han trx.SetOutcome(err, response.LastHTTPStatus(trx.W)) } -func respondOK[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp) { - core.Responder().OK(trx.W, resp) +func respondOK[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp, params HandlerParameters[Req, Resp]) { + responder := core.Responder() + if params.SuccessStatus != 0 || len(params.SuccessHeaders) > 0 { + if sa, ok := responder.(response.StatusAwareResponder); ok { + status := params.SuccessStatus + if status == 0 { + status = http.StatusOK + } + sa.OKWithStatusAndHeaders(trx.W, status, params.SuccessHeaders, resp) + trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) + trx.RespSent = true + return + } + } + responder.OK(trx.W, resp) trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) trx.RespSent = true } -func respondOKWithReceipt[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp, receipt *response.Receipt) { - core.Responder().OKWithReceipt(trx.W, resp, receipt) +func respondOKWithReceipt[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], resp Resp, receipt *response.Receipt, params HandlerParameters[Req, Resp]) { + responder := core.Responder() + if params.SuccessStatus != 0 || len(params.SuccessHeaders) > 0 { + if sa, ok := responder.(response.StatusAwareResponder); ok { + status := params.SuccessStatus + if status == 0 { + status = http.StatusOK + } + sa.OKWithStatusAndHeaders(trx.W, status, params.SuccessHeaders, resp) + trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) + trx.RespSent = true + return + } + } + responder.OKWithReceipt(trx.W, resp, receipt) trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) trx.RespSent = true } -func respondOKWithAttachment[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], attachment *response.FileResponse) { - core.Responder().OKWithAttachment(trx.W, attachment) +func respondOKWithAttachment[Req, Resp any](core requestCore.RequestCoreInterface, trx *HandlerRequest[Req, Resp], attachment *response.FileResponse, params HandlerParameters[Req, Resp]) { + responder := core.Responder() + if len(params.SuccessHeaders) > 0 { + for k, v := range params.SuccessHeaders { + trx.W.Parser.SetRespHeader(k, v) + } + } + responder.OKWithAttachment(trx.W, attachment) trx.SetOutcome(nil, response.LastHTTPStatus(trx.W)) trx.RespSent = true } @@ -247,7 +289,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( return } - respondOK(core, &trx, trx.Response) + respondOK(core, &trx, trx.Response, params) return } @@ -291,7 +333,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if receipt != nil { rc, ok := receipt.(*response.Receipt) if ok { - respondOKWithReceipt(core, &trx, trx.Response, rc) + respondOKWithReceipt(core, &trx, trx.Response, rc, params) } else { slog.Error("registered as handler with receipt, but receipt local was", slog.Any("receipt", fmt.Sprintf("%t", receipt))) } @@ -305,7 +347,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if attachment != nil { rc, ok := attachment.(*response.FileResponse) if ok { - respondOKWithAttachment(core, &trx, rc) + respondOKWithAttachment(core, &trx, rc, params) } else { slog.Error("registered as handler with attachment, but attachment local was", slog.Any("receipt", fmt.Sprintf("%t", attachment))) } @@ -315,7 +357,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } if !trx.RespSent { - respondOK(core, &trx, trx.Response) + respondOK(core, &trx, trx.Response, params) } } } diff --git a/httpsemantics/auth.go b/httpsemantics/auth.go new file mode 100644 index 0000000..2b092ce --- /dev/null +++ b/httpsemantics/auth.go @@ -0,0 +1,187 @@ +// Package httpsemantics provides stdlib-only helpers for HTTP standards +// including OAuth token-response cache headers, RFC 6750 Bearer +// challenges, RFC 9110 conditional requests, RFC 8288 Link headers, and +// RFC 9110 Retry-After parsing/formatting. +// +// These helpers are opt-in and framework-neutral. They do not change +// default behavior of any requestCore handler or responder. +package httpsemantics + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// ApplyTokenResponseNoStore sets the headers required by RFC 6749 §5.1 +// for OAuth 2.0 access-token responses: Cache-Control: no-store and +// Pragma: no-cache. The caller is responsible for determining that the +// response is a token response; this helper only applies the headers. +// +// RFC 6749 §5.1: "The authorization server MUST include the HTTP +// "Cache-Control" response header field [RFC2616] with a value of +// "no-store" in any successful response to the token endpoint. The +// authorization server MUST include the "Pragma" response header field +// [RFC2616] with a value of "no-cache" in any successful response to +// the token endpoint." +func ApplyTokenResponseNoStore(h http.Header) { + h.Set("Cache-Control", "no-store") + h.Set("Pragma", "no-cache") +} + +// ApplyTokenResponseNoStoreToParser sets the no-store headers through a +// SetHeader function (e.g. webFramework.RequestParser.SetRespHeader). +// This is the v1 integration point for framework-neutral header setting. +func ApplyTokenResponseNoStoreToParser(setHeader func(name, value string)) { + setHeader("Cache-Control", "no-store") + setHeader("Pragma", "no-cache") +} + +// BearerError is a standard RFC 6750 Bearer challenge error code. +type BearerError string + +const ( + // BearerErrorInvalidToken indicates the access token provided is + // expired, revoked, malformed, or invalid for other reasons. + BearerErrorInvalidToken BearerError = "invalid_token" + + // BearerErrorInvalidRequest indicates the request is missing a + // required parameter, includes an unsupported parameter or + // parameter value, repeats the same parameter, uses more than one + // method for including an access token, or is otherwise malformed. + BearerErrorInvalidRequest BearerError = "invalid_request" + + // BearerErrorInsufficientScope indicates that the access token + // provided has insufficient scope for the resource being accessed. + BearerErrorInsufficientScope BearerError = "insufficient_scope" + + // BearerErrorMissingToken indicates that no access token was + // provided in the request. This is not a standard RFC 6750 error + // code but is commonly used in WWW-Authenticate challenges to + // distinguish "no token" from "invalid token". + BearerErrorMissingToken BearerError = "missing_token" +) + +// BearerChallenge holds the parameters for an RFC 6750 WWW-Authenticate +// Bearer challenge. All fields are validated and escaped by +// FormatBearerChallenge before being included in the header value. +type BearerChallenge struct { + // Realm is a description of the protected resource. If empty, + // "Protected" is used as a default. + Realm string + + // Error is the RFC 6750 error code. If empty, no error parameter is + // included (used for initial 401 challenges without a specific error). + Error BearerError + + // ErrorDescription is a human-readable description of the error. + // Must not contain characters that require escaping beyond standard + // quoted-string rules. If empty, the parameter is omitted. + ErrorDescription string + + // ErrorURI is a URI identifying a human-readable page with + // information about the error. Must be a valid absolute or relative + // URI. If empty, the parameter is omitted. + ErrorURI string + + // Scope is a space-delimited list of scopes that would suffice for + // the request. If empty, the parameter is omitted. + Scope string +} + +// FormatBearerChallenge formats an RFC 6750 WWW-Authenticate Bearer +// challenge string. All parameter values are validated and escaped to +// prevent header injection. The realm is always included; other +// parameters are included only when non-empty. +// +// The returned string is suitable for setting as the WWW-Authenticate +// response header value on a 401 response from a Bearer-protected +// resource server. +func FormatBearerChallenge(c BearerChallenge) string { + realm := c.Realm + if realm == "" { + realm = "Protected" + } + + var parts []string + parts = append(parts, fmt.Sprintf(`realm="%s"`, escapeQuotedString(realm))) + + if c.Error != "" { + parts = append(parts, fmt.Sprintf(`error="%s"`, escapeQuotedString(string(c.Error)))) + } + + if c.ErrorDescription != "" { + parts = append(parts, fmt.Sprintf(`error_description="%s"`, escapeQuotedString(c.ErrorDescription))) + } + + if c.ErrorURI != "" { + parts = append(parts, fmt.Sprintf(`error_uri="%s"`, escapeQuotedString(c.ErrorURI))) + } + + if c.Scope != "" { + parts = append(parts, fmt.Sprintf(`scope="%s"`, escapeQuotedString(c.Scope))) + } + + return "Bearer " + strings.Join(parts, ", ") +} + +// ApplyBearerChallenge sets the WWW-Authenticate header on an +// http.Header with the given Bearer challenge. The status code should +// be 401 (Unauthorized). The caller is responsible for determining that +// the response is a Bearer-protected resource-server 401; this helper +// does not automatically attach challenges to unrelated 401 responses. +func ApplyBearerChallenge(h http.Header, c BearerChallenge) { + h.Set("WWW-Authenticate", FormatBearerChallenge(c)) +} + +// ApplyBearerChallengeToParser sets the WWW-Authenticate header through +// a SetHeader function (e.g. webFramework.RequestParser.SetRespHeader). +// This is the v1 integration point for framework-neutral header setting. +func ApplyBearerChallengeToParser(setHeader func(name, value string), c BearerChallenge) { + setHeader("WWW-Authenticate", FormatBearerChallenge(c)) +} + +// escapeQuotedString escapes a string for safe inclusion in an RFC 7235 +// quoted-string value. It escapes backslash and double-quote characters +// and rejects control characters (except tab) that are not allowed in +// quoted-string content. Characters that cannot be safely escaped are +// replaced with a space to prevent header injection. +func escapeQuotedString(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '\\' || r == '"': + b.WriteRune('\\') + b.WriteRune(r) + case r == '\t': + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + // Control characters (except tab) are not allowed in + // quoted-string. Replace with space to prevent injection. + b.WriteRune(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// ValidateErrorURI validates that the error_uri is a valid URI reference +// (absolute or relative). This prevents injection of malformed URIs into +// the WWW-Authenticate header. +func ValidateErrorURI(uri string) error { + if uri == "" { + return nil + } + parsed, err := url.Parse(uri) + if err != nil { + return fmt.Errorf("httpsemantics: invalid error_uri: %w", err) + } + // Reject URIs with control characters or whitespace that could + // break the header format. + if strings.ContainsAny(parsed.String(), "\r\n") { + return fmt.Errorf("httpsemantics: error_uri contains CRLF") + } + return nil +} diff --git a/httpsemantics/auth_test.go b/httpsemantics/auth_test.go new file mode 100644 index 0000000..ffdefcf --- /dev/null +++ b/httpsemantics/auth_test.go @@ -0,0 +1,171 @@ +package httpsemantics + +import ( + "net/http" + "testing" +) + +func TestApplyTokenResponseNoStore(t *testing.T) { + h := http.Header{} + ApplyTokenResponseNoStore(h) + if got := h.Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want %q", got, "no-store") + } + if got := h.Get("Pragma"); got != "no-cache" { + t.Errorf("Pragma = %q, want %q", got, "no-cache") + } +} + +func TestApplyTokenResponseNoStoreToParser(t *testing.T) { + var headers map[string]string + setHeader := func(name, value string) { + if headers == nil { + headers = make(map[string]string) + } + headers[name] = value + } + ApplyTokenResponseNoStoreToParser(setHeader) + if headers["Cache-Control"] != "no-store" { + t.Errorf("Cache-Control = %q, want %q", headers["Cache-Control"], "no-store") + } + if headers["Pragma"] != "no-cache" { + t.Errorf("Pragma = %q, want %q", headers["Pragma"], "no-cache") + } +} + +func TestFormatBearerChallenge_DefaultRealm(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{}) + if got != `Bearer realm="Protected"` { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, `Bearer realm="Protected"`) + } +} + +func TestFormatBearerChallenge_CustomRealm(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{Realm: "My API"}) + if got != `Bearer realm="My API"` { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, `Bearer realm="My API"`) + } +} + +func TestFormatBearerChallenge_InvalidToken(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Error: BearerErrorInvalidToken, + ErrorDescription: "The access token expired", + ErrorURI: "https://example.com/oauth/errors", + }) + want := `Bearer realm="Protected", error="invalid_token", error_description="The access token expired", error_uri="https://example.com/oauth/errors"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestFormatBearerChallenge_InsufficientScope(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Error: BearerErrorInsufficientScope, + Scope: "read write admin", + }) + want := `Bearer realm="Protected", error="insufficient_scope", scope="read write admin"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestFormatBearerChallenge_MissingToken(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Error: BearerErrorMissingToken, + }) + want := `Bearer realm="Protected", error="missing_token"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestFormatBearerChallenge_NoError(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Realm: "Protected Resource", + }) + want := `Bearer realm="Protected Resource"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestApplyBearerChallenge(t *testing.T) { + h := http.Header{} + ApplyBearerChallenge(h, BearerChallenge{Error: BearerErrorInvalidToken}) + if got := h.Get("WWW-Authenticate"); got == "" { + t.Error("WWW-Authenticate not set") + } + if got := h.Get("WWW-Authenticate"); got != `Bearer realm="Protected", error="invalid_token"` { + t.Errorf("WWW-Authenticate = %q", got) + } +} + +func TestApplyBearerChallengeToParser(t *testing.T) { + var headers map[string]string + setHeader := func(name, value string) { + if headers == nil { + headers = make(map[string]string) + } + headers[name] = value + } + ApplyBearerChallengeToParser(setHeader, BearerChallenge{Error: BearerErrorInvalidToken}) + if headers["WWW-Authenticate"] == "" { + t.Error("WWW-Authenticate not set") + } +} + +func TestEscapeQuotedString_EscapesBackslash(t *testing.T) { + got := escapeQuotedString(`a\b`) + if got != `a\\b` { + t.Errorf("escapeQuotedString() = %q, want %q", got, `a\\b`) + } +} + +func TestEscapeQuotedString_EscapesQuote(t *testing.T) { + got := escapeQuotedString(`a"b`) + if got != `a\"b` { + t.Errorf("escapeQuotedString() = %q, want %q", got, `a\"b`) + } +} + +func TestEscapeQuotedString_ReplacesControlChars(t *testing.T) { + got := escapeQuotedString("a\x00b\x01c") + if got != "a b c" { + t.Errorf("escapeQuotedString() = %q, want %q", got, "a b c") + } +} + +func TestEscapeQuotedString_PreservesTab(t *testing.T) { + got := escapeQuotedString("a\tb") + if got != "a\tb" { + t.Errorf("escapeQuotedString() = %q, want %q", got, "a\tb") + } +} + +func TestEscapeQuotedString_PreventsHeaderInjection(t *testing.T) { + // Attempt to inject a new header via CRLF. Both \r and \n are + // control characters (< 0x20) and are replaced with spaces. + got := escapeQuotedString("evil\r\nX-Injected: yes") + if got != "evil X-Injected: yes" { + t.Errorf("escapeQuotedString() = %q, want %q", got, "evil X-Injected: yes") + } +} + +func TestValidateErrorURI_Valid(t *testing.T) { + if err := ValidateErrorURI("https://example.com/errors"); err != nil { + t.Errorf("ValidateErrorURI() error = %v", err) + } +} + +func TestValidateErrorURI_Empty(t *testing.T) { + if err := ValidateErrorURI(""); err != nil { + t.Errorf("ValidateErrorURI() error = %v", err) + } +} + +func TestValidateErrorURI_Relative(t *testing.T) { + if err := ValidateErrorURI("/errors/123"); err != nil { + t.Errorf("ValidateErrorURI() error = %v", err) + } +} diff --git a/response/model.go b/response/model.go index fc0ae3f..46de66f 100644 --- a/response/model.go +++ b/response/model.go @@ -16,6 +16,18 @@ type ResponseHandler interface { Error(w webFramework.WebFramework, err error) } +// StatusAwareResponder is an optional interface that responders can +// implement to support configurable success statuses and response +// headers. WebHanlder implements this interface. Custom ResponseHandler +// implementations are not required to implement it; callers that need +// status-aware responses should type-assert to this interface. +// +//revive:disable-next-line:exported +type StatusAwareResponder interface { + OKWithStatus(w webFramework.WebFramework, status int, resp any) + OKWithStatusAndHeaders(w webFramework.WebFramework, status int, headers map[string]string, resp any) +} + // RespType identifies the kind of response payload to send. type RespType int diff --git a/response/problem.go b/response/problem.go new file mode 100644 index 0000000..be8dd6d --- /dev/null +++ b/response/problem.go @@ -0,0 +1,200 @@ +package response + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/hmmftg/requestCore/webFramework" +) + +// ProblemContentType is the media type for RFC 9457 problem details. +const ProblemContentType = "application/problem+json" + +// ProblemViolation represents a single validation violation in a +// Problem response. +type ProblemViolation struct { + // Field is the JSON/query/path/header field name that violated a + // constraint. Uses the wire name, not the Go struct field name. + Field string `json:"field"` + + // Rule is the validation rule that was violated (e.g. "required", + // "min", "max_length"). + Rule string `json:"rule"` + + // Message is a human-readable description of the violation. + Message string `json:"message"` +} + +// Problem implements RFC 9457 Problem Details for HTTP APIs. It +// satisfies the error and Unwrap interfaces. +// +// This is an additive, opt-in facility. The default v1 response path +// continues to use the legacy WsResponse envelope. Problem is intended +// for handlers that explicitly opt into RFC 9457 error responses. +// +// Causes are never serialized by default. Unknown errors always become +// sanitized 500 problems via the mapper registry. +type Problem struct { + // Type is a URI reference identifying the problem type. Defaults to + // "about:blank" per RFC 9457. + Type string `json:"type"` + + // Title is a short, human-readable summary of the problem type. + Title string `json:"title"` + + // Status is the HTTP status code. + Status int `json:"status"` + + // Detail is a human-readable, safe explanation specific to this + // occurrence. Must not leak sensitive data. + Detail string `json:"detail,omitempty"` + + // Instance is a URI reference identifying the specific occurrence. + Instance string `json:"instance,omitempty"` + + // Code is a stable extension identifying the error code. + Code string `json:"code,omitempty"` + + // Violations is an extension listing validation violations. + Violations []ProblemViolation `json:"violations,omitempty"` + + // RequestID is an extension carrying the request identifier. + RequestID string `json:"request_id,omitempty"` + + // TraceID is an extension carrying the trace identifier. + TraceID string `json:"trace_id,omitempty"` + + // cause is the underlying error, never serialized by default. + cause error +} + +// NewProblem creates a Problem with the given status and title. The +// Type defaults to "about:blank". +func NewProblem(status int, title string) *Problem { + return &Problem{ + Type: "about:blank", + Title: title, + Status: status, + } +} + +// NewProblemWithCode creates a Problem with a stable error code extension. +func NewProblemWithCode(status int, title, code string) *Problem { + return &Problem{ + Type: "about:blank", + Title: title, + Status: status, + Code: code, + } +} + +// NewValidationProblem creates a Problem with validation violations. +// The status should typically be 422 (Unprocessable Entity) or 400. +func NewValidationProblem(status int, title string, violations []ProblemViolation) *Problem { + return &Problem{ + Type: "about:blank", + Title: title, + Status: status, + Violations: violations, + } +} + +// Error implements the error interface. It returns a string in the +// format "problem: (<status>)" without exposing the cause. +func (p *Problem) Error() string { + return fmt.Sprintf("problem: %s (%d)", p.Title, p.Status) +} + +// Unwrap returns the underlying cause error, or nil if none was set. +// This supports errors.Is and errors.As chaining. +func (p *Problem) Unwrap() error { + return p.cause +} + +// WithDetail sets the detail and returns the problem for chaining. +func (p *Problem) WithDetail(detail string) *Problem { + p.Detail = detail + return p +} + +// WithInstance sets the instance URI and returns the problem for chaining. +func (p *Problem) WithInstance(instance string) *Problem { + p.Instance = instance + return p +} + +// WithRequestID sets the request ID extension and returns the problem. +func (p *Problem) WithRequestID(id string) *Problem { + p.RequestID = id + return p +} + +// WithTraceID sets the trace ID extension and returns the problem. +func (p *Problem) WithTraceID(id string) *Problem { + p.TraceID = id + return p +} + +// WithCode sets the stable error code extension and returns the problem. +func (p *Problem) WithCode(code string) *Problem { + p.Code = code + return p +} + +// WithCause sets the underlying cause error. The cause is never +// serialized by MarshalJSON. It is accessible via Unwrap for +// errors.Is/errors.As chaining. +func (p *Problem) WithCause(err error) *Problem { + p.cause = err + return p +} + +// MarshalJSON serializes the Problem as RFC 9457 JSON. The cause is +// never included in the output. +func (p *Problem) MarshalJSON() ([]byte, error) { + type alias Problem + return json.Marshal((*alias)(p)) +} + +// WriteToWebFramework writes the Problem as JSON with the correct +// content type through the v1 webFramework. It sets the Content-Type +// header, records the HTTP status in local storage (consistent with +// the legacy respond path), and sends the JSON body via the parser. +// +// This is the v1 integration point for opt-in RFC 9457 responses. +func (p *Problem) WriteToWebFramework(w webFramework.WebFramework) error { + body, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("problem: marshal: %w", err) + } + w.Parser.SetLocal(LastHTTPStatusLocal, p.Status) + w.Parser.SetRespHeader("Content-Type", ProblemContentType) + return w.Parser.SendJSONRespBody(p.Status, json.RawMessage(body)) +} + +// WriteTo writes the Problem as JSON with the correct content type to +// the given http.ResponseWriter. This is provided for direct net/http +// integration outside the webFramework lifecycle. +func (p *Problem) WriteTo(rw http.ResponseWriter) error { + body, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("problem: marshal: %w", err) + } + rw.Header().Set("Content-Type", ProblemContentType) + rw.WriteHeader(p.Status) + _, err = rw.Write(body) + return err +} + +// logValue returns a slog.Value that redacts the cause and any +// sensitive detail. Only status, title, code, and type are logged. +func (p *Problem) logValue() slog.Value { + return slog.GroupValue( + slog.String("type", p.Type), + slog.String("title", p.Title), + slog.Int("status", p.Status), + slog.String("code", p.Code), + ) +} diff --git a/response/problem_mapper.go b/response/problem_mapper.go new file mode 100644 index 0000000..fd0c5c2 --- /dev/null +++ b/response/problem_mapper.go @@ -0,0 +1,274 @@ +package response + +import ( + "errors" + "net/http" + "sync" + + "github.com/hmmftg/requestCore/libError" +) + +// ProblemMapper converts an error into an RFC 9457 Problem. Custom +// mappers return a complete Problem, not raw response bytes, preserving +// central commit and content type. +// +// If the mapper does not match the error, it should return nil so the +// registry can try the next mapper or the fallback. +type ProblemMapper func(err error) *Problem + +// ProblemMatcher determines whether an error should be handled by a +// specific mapper. It typically uses errors.As to inspect the error +// chain. Returns true if the mapper should handle this error. +type ProblemMatcher func(err error) bool + +// ProblemMapperRegistry holds a set of error-to-Problem mappers with +// matchers. When Map is called, the registry checks each registered +// matcher in order; the first matching mapper wins. If no mapper +// matches, the default sanitizer produces a 500 problem. +// +// Unknown errors always become sanitized 500 problems. Causes are +// never serialized by default. +// +// Once frozen, Register and SetFallback return ErrRegistryFrozen. The +// registry should be frozen before serving to prevent runtime mutation. +type ProblemMapperRegistry struct { + mu sync.RWMutex + entries []problemMapperEntry + fallback ProblemMapper + frozen bool +} + +// ErrProblemRegistryFrozen is returned when attempting to register or +// modify a frozen ProblemMapperRegistry. +var ErrProblemRegistryFrozen = errors.New("response: problem mapper registry is frozen") + +type problemMapperEntry struct { + matcher ProblemMatcher + mapper ProblemMapper +} + +// NewProblemMapperRegistry creates an empty ProblemMapperRegistry with +// a default 500 sanitizer fallback. +func NewProblemMapperRegistry() *ProblemMapperRegistry { + return &ProblemMapperRegistry{ + fallback: defaultProblemSanitizerMapper, + } +} + +// DefaultProblemMapperRegistry returns a registry with the default 500 +// sanitizer as the fallback, plus built-in mappers for libError.ErrorData +// and response.ErrorData. This is the standard registry for v1 +// applications that opt into RFC 9457 error responses. +func DefaultProblemMapperRegistry() *ProblemMapperRegistry { + r := NewProblemMapperRegistry() + _ = r.Register(libErrorProblemMatcher, libErrorProblemMapper) + _ = r.Register(errorDataProblemMatcher, errorDataProblemMapper) + return r +} + +// Register associates a mapper with a matcher. When Map encounters an +// error that matches the matcher, the mapper is invoked. Registration +// order matters: the first matching mapper wins. +// +// Returns an error if matcher or mapper is nil. +func (r *ProblemMapperRegistry) Register(matcher ProblemMatcher, mapper ProblemMapper) error { + if matcher == nil { + return errors.New("problem: nil matcher") + } + if mapper == nil { + return errors.New("problem: nil mapper") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.frozen { + return ErrProblemRegistryFrozen + } + r.entries = append(r.entries, problemMapperEntry{matcher: matcher, mapper: mapper}) + return nil +} + +// SetFallback sets the fallback mapper invoked when no registered +// mapper matches. If nil is passed, the default sanitizer is used. +// Returns ErrProblemRegistryFrozen if the registry is frozen. +func (r *ProblemMapperRegistry) SetFallback(mapper ProblemMapper) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.frozen { + return ErrProblemRegistryFrozen + } + if mapper == nil { + mapper = defaultProblemSanitizerMapper + } + r.fallback = mapper + return nil +} + +// Freeze prevents further registration or fallback changes. Called +// after startup before serving requests. +func (r *ProblemMapperRegistry) Freeze() { + r.mu.Lock() + defer r.mu.Unlock() + r.frozen = true +} + +// Frozen reports whether the registry is frozen. +func (r *ProblemMapperRegistry) Frozen() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.frozen +} + +// Map converts an error into a Problem. It checks registered mappers +// in order; the first matching mapper wins. If no mapper matches, +// the fallback sanitizer produces a 500 problem. +// +// If err is nil, Map returns nil. +// If err is already a *Problem, Map returns it as-is. +func (r *ProblemMapperRegistry) Map(err error) *Problem { + if err == nil { + return nil + } + // If already a Problem, return as-is. + var p *Problem + if errors.As(err, &p) { + return p + } + + r.mu.RLock() + // Snapshot entries to avoid concurrent mutation during iteration. + entries := append([]problemMapperEntry(nil), r.entries...) + fallback := r.fallback + r.mu.RUnlock() + + for _, entry := range entries { + if entry.matcher(err) { + if p := entry.mapper(err); p != nil { + return p + } + } + } + return fallback(err) +} + +// defaultProblemSanitizerMapper converts any unknown error into a +// sanitized 500 problem. The error detail is never exposed; only a +// generic "Internal Server Error" is returned. +func defaultProblemSanitizerMapper(err error) *Problem { + return NewProblemWithCode( + http.StatusInternalServerError, + "Internal Server Error", + "INTERNAL", + ) +} + +// libErrorProblemMatcher matches libError.ErrorData errors. +func libErrorProblemMatcher(err error) bool { + var e libError.ErrorData + return errors.As(err, &e) +} + +// libErrorProblemMapper maps libError.ErrorData to a Problem. It uses +// the ActionData.Description as the code, PublicDescription as detail +// when available, and the ActionData.Status as the HTTP status. The +// internal Message is never exposed. +func libErrorProblemMapper(err error) *Problem { + var e libError.ErrorData + if !errors.As(err, &e) { + return nil + } + status := e.ActionData.Status.Int() + if status == 0 { + status = http.StatusInternalServerError + } + p := NewProblemWithCode(status, httpStatusTitle(status), e.ActionData.Description) + if e.ActionData.PublicDescription != "" { + p = p.WithDetail(SanitizeForClient(e.ActionData.PublicDescription, MaxDescriptionLength)) + } + return p.WithCause(err) +} + +// errorDataProblemMatcher matches response.ErrorData errors. Both +// pointer and value forms are matched since the codebase uses both +// (though pointers are the convention for response.ErrorData). +func errorDataProblemMatcher(err error) bool { + var p *ErrorData + if errors.As(err, &p) { + return true + } + var v ErrorData + return errors.As(err, &v) +} + +// errorDataProblemMapper maps response.ErrorData to a Problem. It uses +// the Description as the code and Status as the HTTP status. The +// internal Message is never exposed. +func errorDataProblemMapper(err error) *Problem { + var p *ErrorData + if errors.As(err, &p) { + return mapErrorDataToProblem(p, err) + } + var v ErrorData + if errors.As(err, &v) { + return mapErrorDataToProblem(&v, err) + } + return nil +} + +func mapErrorDataToProblem(e *ErrorData, cause error) *Problem { + status := e.Status + if status == 0 { + status = http.StatusInternalServerError + } + problem := NewProblemWithCode(status, httpStatusTitle(status), e.Description) + + // If Message contains validation errors (ErrorResponse array), map + // them to violations. + if errs, ok := e.Message.([]ErrorResponse); ok && len(errs) > 0 { + violations := make([]ProblemViolation, 0, len(errs)) + for _, er := range errs { + violations = append(violations, ProblemViolation{ + Field: er.Code, + Message: SanitizeForClient(er.Description, MaxDescriptionLength), + }) + } + problem.Violations = violations + } + return problem.WithCause(cause) +} + +// httpStatusTitle returns a standard HTTP status title for the given +// status code, falling back to "Error" for unknown codes. +func httpStatusTitle(status int) string { + switch status { + case http.StatusBadRequest: + return "Bad Request" + case http.StatusUnauthorized: + return "Unauthorized" + case http.StatusForbidden: + return "Forbidden" + case http.StatusNotFound: + return "Not Found" + case http.StatusConflict: + return "Conflict" + case http.StatusUnprocessableEntity: + return "Unprocessable Entity" + case http.StatusTooManyRequests: + return "Too Many Requests" + case http.StatusInternalServerError: + return "Internal Server Error" + case http.StatusNotImplemented: + return "Not Implemented" + case http.StatusBadGateway: + return "Bad Gateway" + case http.StatusServiceUnavailable: + return "Service Unavailable" + case http.StatusGatewayTimeout: + return "Gateway Timeout" + case http.StatusPreconditionFailed: + return "Precondition Failed" + case http.StatusPreconditionRequired: + return "Precondition Required" + default: + return "Error" + } +} diff --git a/response/problem_test.go b/response/problem_test.go new file mode 100644 index 0000000..4546553 --- /dev/null +++ b/response/problem_test.go @@ -0,0 +1,273 @@ +package response + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/hmmftg/requestCore/libError" + "github.com/hmmftg/requestCore/status" +) + +func TestNewProblem(t *testing.T) { + p := NewProblem(http.StatusBadRequest, "Bad Request") + if p.Type != "about:blank" { + t.Errorf("Type = %q, want %q", p.Type, "about:blank") + } + if p.Title != "Bad Request" { + t.Errorf("Title = %q, want %q", p.Title, "Bad Request") + } + if p.Status != http.StatusBadRequest { + t.Errorf("Status = %d, want %d", p.Status, http.StatusBadRequest) + } +} + +func TestNewProblemWithCode(t *testing.T) { + p := NewProblemWithCode(http.StatusConflict, "Conflict", "DUPLICATE") + if p.Code != "DUPLICATE" { + t.Errorf("Code = %q, want %q", p.Code, "DUPLICATE") + } +} + +func TestNewValidationProblem(t *testing.T) { + violations := []ProblemViolation{ + {Field: "email", Rule: "required", Message: "email is required"}, + } + p := NewValidationProblem(http.StatusUnprocessableEntity, "Validation Failed", violations) + if len(p.Violations) != 1 { + t.Fatalf("Violations len = %d, want 1", len(p.Violations)) + } + if p.Violations[0].Field != "email" { + t.Errorf("Violations[0].Field = %q, want %q", p.Violations[0].Field, "email") + } +} + +func TestProblemError(t *testing.T) { + p := NewProblem(http.StatusNotFound, "Not Found") + if got := p.Error(); got != "problem: Not Found (404)" { + t.Errorf("Error() = %q, want %q", got, "problem: Not Found (404)") + } +} + +func TestProblemUnwrap(t *testing.T) { + cause := libError.ErrorData{} + p := NewProblem(http.StatusInternalServerError, "Internal Error").WithCause(cause) + if p.Unwrap() == nil { + t.Error("Unwrap() = nil, want non-nil") + } +} + +func TestProblemWithDetail(t *testing.T) { + p := NewProblem(http.StatusBadRequest, "Bad Request").WithDetail("Invalid email format") + if p.Detail != "Invalid email format" { + t.Errorf("Detail = %q, want %q", p.Detail, "Invalid email format") + } +} + +func TestProblemWithInstance(t *testing.T) { + p := NewProblem(http.StatusBadRequest, "Bad Request").WithInstance("/users/123") + if p.Instance != "/users/123" { + t.Errorf("Instance = %q, want %q", p.Instance, "/users/123") + } +} + +func TestProblemWithRequestID(t *testing.T) { + p := NewProblem(http.StatusBadRequest, "Bad Request").WithRequestID("req-123") + if p.RequestID != "req-123" { + t.Errorf("RequestID = %q, want %q", p.RequestID, "req-123") + } +} + +func TestProblemMarshalJSON_NoCause(t *testing.T) { + p := NewProblemWithCode(http.StatusBadRequest, "Bad Request", "BAD_EMAIL"). + WithDetail("Invalid email"). + WithCause(libError.ErrorData{}) + + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + + // Verify cause is not serialized + if _, exists := m["cause"]; exists { + t.Error("cause field found in JSON output") + } + + // Verify standard fields + if m["type"] != "about:blank" { + t.Errorf("type = %v, want %v", m["type"], "about:blank") + } + if m["title"] != "Bad Request" { + t.Errorf("title = %v, want %v", m["title"], "Bad Request") + } + if m["status"].(float64) != http.StatusBadRequest { + t.Errorf("status = %v, want %d", m["status"], http.StatusBadRequest) + } + if m["code"] != "BAD_EMAIL" { + t.Errorf("code = %v, want %v", m["code"], "BAD_EMAIL") + } +} + +func TestProblemMapperRegistry_NilError(t *testing.T) { + r := NewProblemMapperRegistry() + if p := r.Map(nil); p != nil { + t.Error("Map(nil) should return nil") + } +} + +func TestProblemMapperRegistry_AlreadyProblem(t *testing.T) { + r := NewProblemMapperRegistry() + original := NewProblem(http.StatusNotFound, "Not Found") + p := r.Map(original) + if p != original { + t.Error("Map should return the same Problem when already a *Problem") + } +} + +func TestProblemMapperRegistry_DefaultFallback(t *testing.T) { + r := NewProblemMapperRegistry() + p := r.Map(errSimple("some error")) + if p.Status != http.StatusInternalServerError { + t.Errorf("Status = %d, want %d", p.Status, http.StatusInternalServerError) + } + if p.Title != "Internal Server Error" { + t.Errorf("Title = %q, want %q", p.Title, "Internal Server Error") + } + if p.Code != "INTERNAL" { + t.Errorf("Code = %q, want %q", p.Code, "INTERNAL") + } +} + +func TestProblemMapperRegistry_CustomMapper(t *testing.T) { + r := NewProblemMapperRegistry() + err := r.Register( + func(err error) bool { return true }, + func(err error) *Problem { + return NewProblem(http.StatusTeapot, "I'm a teapot") + }, + ) + if err != nil { + t.Fatalf("Register() error = %v", err) + } + p := r.Map(errSimple("test")) + if p.Status != http.StatusTeapot { + t.Errorf("Status = %d, want %d", p.Status, http.StatusTeapot) + } +} + +func TestProblemMapperRegistry_Freeze(t *testing.T) { + r := NewProblemMapperRegistry() + r.Freeze() + if !r.Frozen() { + t.Error("Frozen() = false, want true") + } + err := r.Register( + func(err error) bool { return true }, + func(err error) *Problem { return NewProblem(http.StatusTeapot, "teapot") }, + ) + if err != ErrProblemRegistryFrozen { + t.Errorf("Register() error = %v, want %v", err, ErrProblemRegistryFrozen) + } +} + +func TestDefaultProblemMapperRegistry_LibError(t *testing.T) { + r := DefaultProblemMapperRegistry() + err := libError.ErrorData{ + ActionData: libError.Action{ + Status: status.BadRequest, + Description: "BAD_INPUT", + PublicDescription: "The input was invalid", + }, + } + p := r.Map(err) + if p.Status != http.StatusBadRequest { + t.Errorf("Status = %d, want %d", p.Status, http.StatusBadRequest) + } + if p.Code != "BAD_INPUT" { + t.Errorf("Code = %q, want %q", p.Code, "BAD_INPUT") + } + if p.Detail != "The input was invalid" { + t.Errorf("Detail = %q, want %q", p.Detail, "The input was invalid") + } +} + +func TestDefaultProblemMapperRegistry_ErrorData(t *testing.T) { + r := DefaultProblemMapperRegistry() + err := &ErrorData{ + Status: http.StatusNotFound, + Description: "NOT_FOUND", + } + p := r.Map(err) + if p.Status != http.StatusNotFound { + t.Errorf("Status = %d, want %d", p.Status, http.StatusNotFound) + } + if p.Code != "NOT_FOUND" { + t.Errorf("Code = %q, want %q", p.Code, "NOT_FOUND") + } +} + +func TestDefaultProblemMapperRegistry_ErrorDataWithViolations(t *testing.T) { + r := DefaultProblemMapperRegistry() + err := &ErrorData{ + Status: http.StatusBadRequest, + Description: "VALIDATION_ERROR", + Message: []ErrorResponse{ + {Code: "email", Description: "email is required"}, + {Code: "name", Description: "name is required"}, + }, + } + p := r.Map(err) + if p.Status != http.StatusBadRequest { + t.Errorf("Status = %d, want %d", p.Status, http.StatusBadRequest) + } + if len(p.Violations) != 2 { + t.Fatalf("Violations len = %d, want 2", len(p.Violations)) + } + if p.Violations[0].Field != "email" { + t.Errorf("Violations[0].Field = %q, want %q", p.Violations[0].Field, "email") + } +} + +func TestDefaultProblemMapperRegistry_UnknownError(t *testing.T) { + r := DefaultProblemMapperRegistry() + p := r.Map(errSimple("unknown error")) + if p.Status != http.StatusInternalServerError { + t.Errorf("Status = %d, want %d", p.Status, http.StatusInternalServerError) + } + // Detail should not contain the raw error + if p.Detail != "" { + t.Errorf("Detail = %q, want empty (sanitized)", p.Detail) + } +} + +func TestHTTPStatusTitle(t *testing.T) { + tests := []struct { + status int + want string + }{ + {http.StatusBadRequest, "Bad Request"}, + {http.StatusUnauthorized, "Unauthorized"}, + {http.StatusForbidden, "Forbidden"}, + {http.StatusNotFound, "Not Found"}, + {http.StatusConflict, "Conflict"}, + {http.StatusInternalServerError, "Internal Server Error"}, + {http.StatusTooManyRequests, "Too Many Requests"}, + {http.StatusPreconditionFailed, "Precondition Failed"}, + {http.StatusPreconditionRequired, "Precondition Required"}, + {999, "Error"}, + } + for _, tt := range tests { + if got := httpStatusTitle(tt.status); got != tt.want { + t.Errorf("httpStatusTitle(%d) = %q, want %q", tt.status, got, tt.want) + } + } +} + +type errSimple string + +func (e errSimple) Error() string { return string(e) } diff --git a/response/webHandler.go b/response/webHandler.go index e3969f3..578d2e5 100644 --- a/response/webHandler.go +++ b/response/webHandler.go @@ -102,6 +102,25 @@ func (m WebHanlder) OK(w webFramework.WebFramework, resp any) { m.Respond(http.StatusOK, 0, "OK", resp, false, w) } +// OKWithStatus sends a successful JSON response with the given HTTP +// status and data. The status must be a valid 2xx code. For 204 and +// 205, the response body is suppressed (no JSON is written). This is +// an additive, opt-in method; the default OK continues to use 200. +func (m WebHanlder) OKWithStatus(w webFramework.WebFramework, status int, resp any) { + m.Respond(status, 0, "OK", resp, false, w) +} + +// OKWithStatusAndHeaders sends a successful JSON response with the +// given HTTP status, static response headers, and data. Headers are +// applied before the body is written. For 204 and 205, the body is +// suppressed but headers are still applied. +func (m WebHanlder) OKWithStatusAndHeaders(w webFramework.WebFramework, status int, headers map[string]string, resp any) { + for k, v := range headers { + w.Parser.SetRespHeader(k, v) + } + m.Respond(status, 0, "OK", resp, false, w) +} + // OKWithReceipt sends a successful JSON response with an optional printable receipt. func (m WebHanlder) OKWithReceipt(w webFramework.WebFramework, resp any, receipt *Receipt) { m.RespondWithReceipt(http.StatusOK, 0, "OK", resp, receipt, false, w) @@ -150,7 +169,9 @@ func (m WebHanlder) respond(data RespData, abort bool, w webFramework.WebFramewo w.Parser.SetLocal(LastHTTPStatusLocal, data.Code) webFramework.AddLogTag(w, webFramework.HandlerLogTag, slog.Int("status", data.Code)) - if data.Code == http.StatusOK { + if data.Status == 0 { + // Success path: status 0 means success. This allows any 2xx + // HTTP status to be used for success responses, not just 200. resp.Description = m.MessageDesc[data.Message] switch data.Type { case FileAttachment: @@ -159,12 +180,24 @@ func (m WebHanlder) respond(data RespData, abort bool, w webFramework.WebFramewo resp.PrintReceipt = data.PrintData fallthrough case JSON: - resp.Result = data.JSON - - err := w.Parser.SendJSONRespBody(data.Code, resp) - if err != nil { - webFramework.AddLog(w, webFramework.HandlerLogTag, - slog.Group("error in SendJSONRespBody", slog.Any("error", err))) + // Suppress body for 204 No Content and 205 Reset Content + // per RFC 9110. A 204/205 response must not have a body. + if data.Code == http.StatusNoContent || data.Code == http.StatusResetContent { + // Send only the status code without a body. Use + // SendJSONRespBody with nil to set the status without + // writing JSON content. + err := w.Parser.SendJSONRespBody(data.Code, nil) + if err != nil { + webFramework.AddLog(w, webFramework.HandlerLogTag, + slog.Group("error in SendJSONRespBody", slog.Any("error", err))) + } + } else { + resp.Result = data.JSON + err := w.Parser.SendJSONRespBody(data.Code, resp) + if err != nil { + webFramework.AddLog(w, webFramework.HandlerLogTag, + slog.Group("error in SendJSONRespBody", slog.Any("error", err))) + } } } } else { diff --git a/v2/httpsemantics/auth.go b/v2/httpsemantics/auth.go new file mode 100644 index 0000000..ab7179b --- /dev/null +++ b/v2/httpsemantics/auth.go @@ -0,0 +1,129 @@ +// Package httpsemantics provides stdlib-only helpers for HTTP standards +// including OAuth token-response cache headers, RFC 6750 Bearer +// challenges, RFC 9110 conditional requests, RFC 8288 Link headers, and +// RFC 9110 Retry-After parsing/formatting. +// +// These helpers are opt-in and framework-neutral. They do not change +// default behavior of any v2 handler or responder. +package httpsemantics + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// ApplyTokenResponseNoStore sets the headers required by RFC 6749 §5.1 +// for OAuth 2.0 access-token responses: Cache-Control: no-store and +// Pragma: no-cache. The caller is responsible for determining that the +// response is a token response; this helper only applies the headers. +func ApplyTokenResponseNoStore(h http.Header) { + h.Set("Cache-Control", "no-store") + h.Set("Pragma", "no-cache") +} + +// BearerError is a standard RFC 6750 Bearer challenge error code. +type BearerError string + +const ( + // BearerErrorInvalidToken indicates the access token provided is + // expired, revoked, malformed, or invalid for other reasons. + BearerErrorInvalidToken BearerError = "invalid_token" + + // BearerErrorInvalidRequest indicates the request is missing a + // required parameter, includes an unsupported parameter or + // parameter value, repeats the same parameter, uses more than one + // method for including an access token, or is otherwise malformed. + BearerErrorInvalidRequest BearerError = "invalid_request" + + // BearerErrorInsufficientScope indicates that the access token + // provided has insufficient scope for the resource being accessed. + BearerErrorInsufficientScope BearerError = "insufficient_scope" + + // BearerErrorMissingToken indicates that no access token was + // provided in the request. + BearerErrorMissingToken BearerError = "missing_token" +) + +// BearerChallenge holds the parameters for an RFC 6750 WWW-Authenticate +// Bearer challenge. +type BearerChallenge struct { + Realm string + Error BearerError + ErrorDescription string + ErrorURI string + Scope string +} + +// FormatBearerChallenge formats an RFC 6750 WWW-Authenticate Bearer +// challenge string. All parameter values are validated and escaped to +// prevent header injection. +func FormatBearerChallenge(c BearerChallenge) string { + realm := c.Realm + if realm == "" { + realm = "Protected" + } + + var parts []string + parts = append(parts, fmt.Sprintf(`realm="%s"`, escapeQuotedString(realm))) + + if c.Error != "" { + parts = append(parts, fmt.Sprintf(`error="%s"`, escapeQuotedString(string(c.Error)))) + } + + if c.ErrorDescription != "" { + parts = append(parts, fmt.Sprintf(`error_description="%s"`, escapeQuotedString(c.ErrorDescription))) + } + + if c.ErrorURI != "" { + parts = append(parts, fmt.Sprintf(`error_uri="%s"`, escapeQuotedString(c.ErrorURI))) + } + + if c.Scope != "" { + parts = append(parts, fmt.Sprintf(`scope="%s"`, escapeQuotedString(c.Scope))) + } + + return "Bearer " + strings.Join(parts, ", ") +} + +// ApplyBearerChallenge sets the WWW-Authenticate header on an +// http.Header with the given Bearer challenge. +func ApplyBearerChallenge(h http.Header, c BearerChallenge) { + h.Set("WWW-Authenticate", FormatBearerChallenge(c)) +} + +// escapeQuotedString escapes a string for safe inclusion in an RFC 7235 +// quoted-string value. +func escapeQuotedString(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '\\' || r == '"': + b.WriteRune('\\') + b.WriteRune(r) + case r == '\t': + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + b.WriteRune(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// ValidateErrorURI validates that the error_uri is a valid URI reference. +func ValidateErrorURI(uri string) error { + if uri == "" { + return nil + } + parsed, err := url.Parse(uri) + if err != nil { + return fmt.Errorf("httpsemantics: invalid error_uri: %w", err) + } + if strings.ContainsAny(parsed.String(), "\r\n") { + return fmt.Errorf("httpsemantics: error_uri contains CRLF") + } + return nil +} diff --git a/v2/httpsemantics/auth_test.go b/v2/httpsemantics/auth_test.go new file mode 100644 index 0000000..faa979c --- /dev/null +++ b/v2/httpsemantics/auth_test.go @@ -0,0 +1,74 @@ +package httpsemantics + +import ( + "net/http" + "testing" +) + +func TestApplyTokenResponseNoStore(t *testing.T) { + h := http.Header{} + ApplyTokenResponseNoStore(h) + if got := h.Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want %q", got, "no-store") + } + if got := h.Get("Pragma"); got != "no-cache" { + t.Errorf("Pragma = %q, want %q", got, "no-cache") + } +} + +func TestFormatBearerChallenge_DefaultRealm(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{}) + if got != `Bearer realm="Protected"` { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, `Bearer realm="Protected"`) + } +} + +func TestFormatBearerChallenge_InvalidToken(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Error: BearerErrorInvalidToken, + ErrorDescription: "The access token expired", + ErrorURI: "https://example.com/oauth/errors", + }) + want := `Bearer realm="Protected", error="invalid_token", error_description="The access token expired", error_uri="https://example.com/oauth/errors"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestFormatBearerChallenge_InsufficientScope(t *testing.T) { + got := FormatBearerChallenge(BearerChallenge{ + Error: BearerErrorInsufficientScope, + Scope: "read write admin", + }) + want := `Bearer realm="Protected", error="insufficient_scope", scope="read write admin"` + if got != want { + t.Errorf("FormatBearerChallenge() = %q, want %q", got, want) + } +} + +func TestApplyBearerChallenge(t *testing.T) { + h := http.Header{} + ApplyBearerChallenge(h, BearerChallenge{Error: BearerErrorInvalidToken}) + if got := h.Get("WWW-Authenticate"); got == "" { + t.Error("WWW-Authenticate not set") + } +} + +func TestEscapeQuotedString_PreventsHeaderInjection(t *testing.T) { + got := escapeQuotedString("evil\r\nX-Injected: yes") + if got != "evil X-Injected: yes" { + t.Errorf("escapeQuotedString() = %q, want %q", got, "evil X-Injected: yes") + } +} + +func TestValidateErrorURI_Valid(t *testing.T) { + if err := ValidateErrorURI("https://example.com/errors"); err != nil { + t.Errorf("ValidateErrorURI() error = %v", err) + } +} + +func TestValidateErrorURI_Empty(t *testing.T) { + if err := ValidateErrorURI(""); err != nil { + t.Errorf("ValidateErrorURI() error = %v", err) + } +} diff --git a/v2/response/regression_phase2_test.go b/v2/response/regression_phase2_test.go new file mode 100644 index 0000000..2415c68 --- /dev/null +++ b/v2/response/regression_phase2_test.go @@ -0,0 +1,142 @@ +package response + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hmmftg/requestCore/v2/request" +) + +// Regression tests for Phase 2: 201 + Location, 204 no body, HEAD +// body suppression, and mapper sanitization. + +func TestWriteSuccess_201WithLocation(t *testing.T) { + ctx := request.NewContext(nil) + transport := &helpersTransport{} + + ctx.Response().AddHeader("Location", "/resources/42") + body := []byte(`{"id":42,"name":"created"}`) + if err := WriteSuccess(ctx, transport, http.StatusCreated, "application/json", body); err != nil { + t.Fatalf("WriteSuccess failed: %v", err) + } + if transport.status != http.StatusCreated { + t.Fatalf("expected 201, got %d", transport.status) + } + if transport.headers.Get("Location") != "/resources/42" { + t.Fatalf("expected Location /resources/42, got %q", transport.headers.Get("Location")) + } + if string(transport.body) != `{"id":42,"name":"created"}` { + t.Fatalf("expected body, got %q", string(transport.body)) + } +} + +func TestWriteSuccess_204NoBody(t *testing.T) { + ctx := request.NewContext(nil) + transport := &helpersTransport{} + + // 204 should suppress the body even if one is provided + if err := WriteSuccess(ctx, transport, http.StatusNoContent, "application/json", []byte(`{}`)); err != nil { + t.Fatalf("WriteSuccess failed: %v", err) + } + if transport.status != http.StatusNoContent { + t.Fatalf("expected 204, got %d", transport.status) + } + if transport.body != nil { + t.Fatalf("expected nil body for 204, got %q", string(transport.body)) + } +} + +func TestNoContent_NoBody(t *testing.T) { + ctx := request.NewContext(nil) + transport := &helpersTransport{} + + if err := NoContent(ctx, transport); err != nil { + t.Fatalf("NoContent failed: %v", err) + } + if transport.status != http.StatusNoContent { + t.Fatalf("expected 204, got %d", transport.status) + } + if transport.body != nil { + t.Fatalf("expected nil body, got %v", transport.body) + } +} + +func TestWriteSuccess_HEADSuppressesBody(t *testing.T) { + ctx := request.NewContext(nil) + transport := &helpersTransport{} + + // Simulate HEAD request by suppressing the body via response state + ctx.Response().SuppressBody() + body := []byte(`{"data":"should be suppressed"}`) + if err := WriteSuccess(ctx, transport, http.StatusOK, "application/json", body); err != nil { + t.Fatalf("WriteSuccess failed: %v", err) + } + if transport.status != http.StatusOK { + t.Fatalf("expected 200, got %d", transport.status) + } + // HEAD requests should suppress the body + if transport.body != nil { + t.Fatalf("expected nil body for HEAD, got %q", string(transport.body)) + } +} + +func TestMapperSanitization_UnknownError(t *testing.T) { + r := NewMapperRegistry() + p := r.Map(errors.New("database password is secret123 and the connection string is postgres://user:pass@host:5432/db")) + + if p.Status != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", p.Status) + } + if p.Title != "Internal Server Error" { + t.Fatalf("expected 'Internal Server Error', got %q", p.Title) + } + // The detail must not contain the raw error message + if p.Detail != "" { + t.Fatalf("expected empty detail (sanitized), got %q", p.Detail) + } + // Verify the raw error is not in the JSON output + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + str := string(body) + if strings.Contains(str, "secret123") { + t.Fatalf("raw error leaked into JSON: %s", str) + } + if strings.Contains(str, "postgres://user:pass") { + t.Fatalf("connection string leaked into JSON: %s", str) + } +} + +func TestMapperSanitization_CauseNeverSerialized(t *testing.T) { + r := NewMapperRegistry() + _ = r.Register( + func(err error) bool { return true }, + func(err error) *Problem { + return NewProblem(http.StatusConflict, "Conflict"). + WithDetail("duplicate resource"). + WithCause(err) + }, + ) + + cause := errors.New("internal: password=hunter2, token=abc123") + p := r.Map(cause) + + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + str := string(body) + if strings.Contains(str, "hunter2") { + t.Fatalf("cause leaked into JSON: %s", str) + } + if strings.Contains(str, "abc123") { + t.Fatalf("cause leaked into JSON: %s", str) + } + if strings.Contains(str, "password") { + t.Fatalf("cause leaked into JSON: %s", str) + } +} From ed84e9f8ef249ec7c870217d29b1209c088c50d3 Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi <h.malekmohammadi@stts.ir> Date: Tue, 8 Sep 2026 14:34:35 +0330 Subject: [PATCH 3/5] =?UTF-8?q?feat(http):=20Phase=203=20=E2=80=94=20condi?= =?UTF-8?q?tional=20requests,=20Link,=20Retry-After,=20Trace=20Context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add httpsemantics conditional request helpers (ETag parsing, If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since) with RFC 9110 precondition evaluation in precedence order. Add RFC 8288 Link header serialization with pagination link builder that preserves query params and omits unavailable relations. Add RFC 9110 Retry-After parsing (delta-seconds and HTTP-date) with clamping to prevent DoS. Add W3C Trace Context extraction/injection helpers for inbound and outbound propagation. Extend libCallApi.RemoteCallError with Headers field and populate it in StatusPreservingBuilder and handlers.callApi. Add HonorRetryAfter and MaxRetryAfterDelay to libRetry.RetryPolicy so the retry loop honors server-provided Retry-After delays (clamped) instead of fixed backoff when enabled. Mirror all helpers in v2/httpsemantics. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- handlers/callApi.go | 11 +- httpsemantics/conditional.go | 246 ++++++++++++++++++++++ httpsemantics/conditional_test.go | 260 ++++++++++++++++++++++++ httpsemantics/link.go | 291 +++++++++++++++++++++++++++ httpsemantics/link_test.go | 209 +++++++++++++++++++ httpsemantics/retry_after.go | 102 ++++++++++ httpsemantics/retry_after_test.go | 177 ++++++++++++++++ httpsemantics/trace.go | 69 +++++++ httpsemantics/trace_test.go | 109 ++++++++++ libCallApi/builder.go | 11 +- libCallApi/errors.go | 7 +- libRetry/retry.go | 80 +++++++- libRetry/retry_test.go | 174 ++++++++++++++++ v2/httpsemantics/conditional.go | 246 ++++++++++++++++++++++ v2/httpsemantics/conditional_test.go | 260 ++++++++++++++++++++++++ v2/httpsemantics/link.go | 291 +++++++++++++++++++++++++++ v2/httpsemantics/link_test.go | 209 +++++++++++++++++++ v2/httpsemantics/retry_after.go | 102 ++++++++++ v2/httpsemantics/retry_after_test.go | 177 ++++++++++++++++ v2/httpsemantics/trace.go | 69 +++++++ v2/httpsemantics/trace_test.go | 109 ++++++++++ 21 files changed, 3198 insertions(+), 11 deletions(-) create mode 100644 httpsemantics/conditional.go create mode 100644 httpsemantics/conditional_test.go create mode 100644 httpsemantics/link.go create mode 100644 httpsemantics/link_test.go create mode 100644 httpsemantics/retry_after.go create mode 100644 httpsemantics/retry_after_test.go create mode 100644 httpsemantics/trace.go create mode 100644 httpsemantics/trace_test.go create mode 100644 v2/httpsemantics/conditional.go create mode 100644 v2/httpsemantics/conditional_test.go create mode 100644 v2/httpsemantics/link.go create mode 100644 v2/httpsemantics/link_test.go create mode 100644 v2/httpsemantics/retry_after.go create mode 100644 v2/httpsemantics/retry_after_test.go create mode 100644 v2/httpsemantics/trace.go create mode 100644 v2/httpsemantics/trace_test.go diff --git a/handlers/callApi.go b/handlers/callApi.go index 99f1005..e351880 100644 --- a/handlers/callApi.go +++ b/handlers/callApi.go @@ -327,10 +327,15 @@ func executeSingleAttempt[Req any, Resp any]( param.Builder = func(stat int, rawResp []byte, headers map[string]string) (*Resp, error) { actualStatus = stat if stat < 200 || stat >= 300 { + hdr := make(http.Header) + for k, v := range headers { + hdr.Set(k, v) + } return nil, &libCallApi.RemoteCallError{ - Status: stat, - Body: rawResp, - Err: fmt.Errorf("HTTP %d", stat), + Status: stat, + Body: rawResp, + Headers: hdr, + Err: fmt.Errorf("HTTP %d", stat), } } return originalBuilder(stat, rawResp, headers) diff --git a/httpsemantics/conditional.go b/httpsemantics/conditional.go new file mode 100644 index 0000000..5a4acba --- /dev/null +++ b/httpsemantics/conditional.go @@ -0,0 +1,246 @@ +package httpsemantics + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// ETag represents an RFC 9110 entity tag with optional weak indicator. +type ETag struct { + // Weak indicates whether this is a weak entity tag (W/ prefix). + Weak bool + + // Value is the opaque entity-tag value without the surrounding + // double quotes or W/ prefix. + Value string +} + +// String formats the ETag as an RFC 9110 entity-tag value: +// `W/"value"` for weak, `"value"` for strong. +func (e ETag) String() string { + if e.Weak { + return fmt.Sprintf(`W/"%s"`, e.Value) + } + return fmt.Sprintf(`"%s"`, e.Value) +} + +// StrongEqual reports whether two ETags are strongly equal per +// RFC 9110 §8.8.3.2: both must be strong and their values must match. +func (e ETag) StrongEqual(other ETag) bool { + return !e.Weak && !other.Weak && e.Value == other.Value +} + +// WeakEqual reports whether two ETags are weakly equal per +// RFC 9110 §8.8.3.2: their values must match, regardless of weakness. +func (e ETag) WeakEqual(other ETag) bool { + return e.Value == other.Value +} + +// ParseETag parses a single RFC 9110 entity-tag value. Supports both +// strong ("value") and weak (W/"value") forms. Returns an error for +// malformed input. +func ParseETag(s string) (ETag, error) { + s = strings.TrimSpace(s) + if s == "" { + return ETag{}, fmt.Errorf("httpsemantics: empty entity tag") + } + + weak := false + if strings.HasPrefix(s, "W/") { + weak = true + s = s[2:] + } + + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return ETag{}, fmt.Errorf("httpsemantics: malformed entity tag: %q", s) + } + + value := s[1 : len(s)-1] + if strings.ContainsAny(value, `"`) { + return ETag{}, fmt.Errorf("httpsemantics: unescaped quote in entity tag: %q", s) + } + + return ETag{Weak: weak, Value: value}, nil +} + +// ParseETagList parses a comma-separated list of entity tags from a +// header value (e.g. If-Match, If-None-Match). Returns the parsed +// ETags. The wildcard "*" is represented as ETag{Value: "*"}. +func ParseETagList(s string) ([]ETag, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + + if s == "*" { + return []ETag{{Value: "*"}}, nil + } + + parts := strings.Split(s, ",") + tags := make([]ETag, 0, len(parts)) + for _, part := range parts { + tag, err := ParseETag(part) + if err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, nil +} + +// FormatETagList formats a slice of ETags as a comma-separated header +// value. +func FormatETagList(tags []ETag) string { + parts := make([]string, len(tags)) + for i, t := range tags { + parts[i] = t.String() + } + return strings.Join(parts, ", ") +} + +// PreconditionResult indicates the outcome of precondition evaluation. +type PreconditionResult int + +const ( + // PreconditionProceed means the request should proceed normally. + PreconditionProceed PreconditionResult = iota + + // PreconditionNotModified means the server should respond with 304 + // Not Modified. + PreconditionNotModified + + // PreconditionFailed means the server should respond with 412 + // Precondition Failed. + PreconditionFailed + + // PreconditionRequired means the server should respond with 428 + // Precondition Required (the resource requires a precondition + // that was not provided). + PreconditionRequired +) + +// PreconditionInput holds the request headers and resource state needed +// to evaluate RFC 9110 preconditions. +type PreconditionInput struct { + // IfMatch is the value of the If-Match header. Empty if absent. + IfMatch string + + // IfNoneMatch is the value of the If-None-Match header. Empty if absent. + IfNoneMatch string + + // IfModifiedSince is the value of the If-Modified-Since header. + // Empty if absent. + IfModifiedSince string + + // IfUnmodifiedSince is the value of the If-Unmodified-Since header. + // Empty if absent. + IfUnmodifiedSince string + + // ResourceETag is the current entity tag of the resource. + // Empty if the resource has no ETag. + ResourceETag string + + // ResourceModified is the last modification time of the resource. + // Zero if the resource has no Last-Modified. + ResourceModified time.Time + + // IsSafeMethod is true for GET and HEAD (where If-None-Match can + // produce 304 rather than 412). + IsSafeMethod bool +} + +// EvaluatePreconditions evaluates RFC 9110 §13.1.2 precondition headers +// in precedence order and returns the appropriate result. +// +// Precedence (RFC 9110 §13.2.2): +// 1. If-Match +// 2. If-Unmodified-Since +// 3. If-None-Match +// 4. If-Modified-Since +// +// If-Match and If-Unmodified-Since produce 412 on failure. +// If-None-Match and If-Modified-Since produce 304 on failure for safe +// methods, 412 for unsafe methods. +func EvaluatePreconditions(in PreconditionInput) PreconditionResult { + // 1. If-Match + if in.IfMatch != "" { + tags, err := ParseETagList(in.IfMatch) + if err != nil { + return PreconditionFailed + } + if !matchETag(tags, in.ResourceETag) { + return PreconditionFailed + } + } + + // 2. If-Unmodified-Since + if in.IfUnmodifiedSince != "" { + since, err := http.ParseTime(in.IfUnmodifiedSince) + if err != nil { + return PreconditionFailed + } + if !in.ResourceModified.IsZero() && in.ResourceModified.After(since) { + return PreconditionFailed + } + } + + // 3. If-None-Match + if in.IfNoneMatch != "" { + tags, err := ParseETagList(in.IfNoneMatch) + if err != nil { + return PreconditionFailed + } + if matchETag(tags, in.ResourceETag) { + if in.IsSafeMethod { + return PreconditionNotModified + } + return PreconditionFailed + } + } + + // 4. If-Modified-Since + if in.IfModifiedSince != "" { + since, err := http.ParseTime(in.IfModifiedSince) + if err != nil { + return PreconditionFailed + } + if in.IsSafeMethod && !in.ResourceModified.IsZero() && !in.ResourceModified.After(since) { + return PreconditionNotModified + } + } + + return PreconditionProceed +} + +// matchETag checks whether the resource ETag matches any tag in the +// provided list. The wildcard "*" matches any non-empty resource ETag. +func matchETag(tags []ETag, resourceETag string) bool { + if resourceETag == "" { + return false + } + + resourceTag, err := ParseETag(resourceETag) + if err != nil { + return false + } + + for _, tag := range tags { + if tag.Value == "*" { + return true + } + if tag.WeakEqual(resourceTag) { + return true + } + } + return false +} + +// IsNoBodyStatus reports whether the given HTTP status code should not +// have a response body per RFC 9110. +func IsNoBodyStatus(status int) bool { + return status == http.StatusNoContent || + status == http.StatusResetContent || + status == http.StatusNotModified +} diff --git a/httpsemantics/conditional_test.go b/httpsemantics/conditional_test.go new file mode 100644 index 0000000..526cfa5 --- /dev/null +++ b/httpsemantics/conditional_test.go @@ -0,0 +1,260 @@ +package httpsemantics + +import ( + "net/http" + "testing" + "time" +) + +func TestETagString_Strong(t *testing.T) { + e := ETag{Value: "abc123"} + if got := e.String(); got != `"abc123"` { + t.Errorf("String() = %q, want %q", got, `"abc123"`) + } +} + +func TestETagString_Weak(t *testing.T) { + e := ETag{Weak: true, Value: "abc123"} + if got := e.String(); got != `W/"abc123"` { + t.Errorf("String() = %q, want %q", got, `W/"abc123"`) + } +} + +func TestParseETag_Strong(t *testing.T) { + e, err := ParseETag(`"abc123"`) + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if e.Weak { + t.Error("Weak = true, want false") + } + if e.Value != "abc123" { + t.Errorf("Value = %q, want %q", e.Value, "abc123") + } +} + +func TestParseETag_Weak(t *testing.T) { + e, err := ParseETag(`W/"abc123"`) + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if !e.Weak { + t.Error("Weak = false, want true") + } + if e.Value != "abc123" { + t.Errorf("Value = %q, want %q", e.Value, "abc123") + } +} + +func TestParseETag_Empty(t *testing.T) { + _, err := ParseETag("") + if err == nil { + t.Error("ParseETag(\"\") should error") + } +} + +func TestParseETag_Malformed(t *testing.T) { + _, err := ParseETag("abc123") + if err == nil { + t.Error("ParseETag(\"abc123\") should error") + } +} + +func TestParseETag_UnescapedQuote(t *testing.T) { + _, err := ParseETag(`"ab"c"`) + if err == nil { + t.Error("ParseETag with unescaped quote should error") + } +} + +func TestParseETagList_Single(t *testing.T) { + tags, err := ParseETagList(`"abc"`) + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 1 { + t.Fatalf("len = %d, want 1", len(tags)) + } +} + +func TestParseETagList_Multiple(t *testing.T) { + tags, err := ParseETagList(`"abc", "def", W/"ghi"`) + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 3 { + t.Fatalf("len = %d, want 3", len(tags)) + } + if tags[2].Value != "ghi" || !tags[2].Weak { + t.Errorf("tags[2] = %+v, want weak ghi", tags[2]) + } +} + +func TestParseETagList_Wildcard(t *testing.T) { + tags, err := ParseETagList("*") + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 1 || tags[0].Value != "*" { + t.Fatalf("tags = %+v, want wildcard", tags) + } +} + +func TestETagStrongEqual(t *testing.T) { + a := ETag{Value: "abc"} + b := ETag{Value: "abc"} + if !a.StrongEqual(b) { + t.Error("StrongEqual should be true for matching strong tags") + } + c := ETag{Weak: true, Value: "abc"} + if a.StrongEqual(c) { + t.Error("StrongEqual should be false when one is weak") + } +} + +func TestETagWeakEqual(t *testing.T) { + a := ETag{Value: "abc"} + b := ETag{Weak: true, Value: "abc"} + if !a.WeakEqual(b) { + t.Error("WeakEqual should be true regardless of weakness") + } +} + +func TestEvaluatePreconditions_IfMatch_Match(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_NoMatch(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_Wildcard(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `*`, + ResourceETag: `"anything"`, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_Wildcard_NoResource(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `*`, + ResourceETag: "", + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfNoneMatch_SafeMethod_304(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + }) + if result != PreconditionNotModified { + t.Errorf("result = %v, want PreconditionNotModified", result) + } +} + +func TestEvaluatePreconditions_IfNoneMatch_UnsafeMethod_412(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: false, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfModifiedSince_NotModified(t *testing.T) { + modTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfModifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionNotModified { + t.Errorf("result = %v, want PreconditionNotModified", result) + } +} + +func TestEvaluatePreconditions_IfModifiedSince_Modified(t *testing.T) { + modTime := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfModifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfUnmodifiedSince_Modified(t *testing.T) { + modTime := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfUnmodifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_NoPreconditions(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_Precedence_IfMatchBeforeIfNoneMatch(t *testing.T) { + // If-Match fails, If-None-Match would succeed. If-Match should win. + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + IfNoneMatch: `"def"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed (If-Match takes precedence)", result) + } +} + +func TestIsNoBodyStatus(t *testing.T) { + if !IsNoBodyStatus(http.StatusNoContent) { + t.Error("204 should be no-body") + } + if !IsNoBodyStatus(http.StatusNotModified) { + t.Error("304 should be no-body") + } + if IsNoBodyStatus(http.StatusOK) { + t.Error("200 should not be no-body") + } +} diff --git a/httpsemantics/link.go b/httpsemantics/link.go new file mode 100644 index 0000000..54c4b71 --- /dev/null +++ b/httpsemantics/link.go @@ -0,0 +1,291 @@ +package httpsemantics + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// Link represents a single RFC 8288 Web Link with a target URI and +// optional parameters. +type Link struct { + // URI is the link target. May be relative or absolute. + URI string + + // Rel is the link relation type (e.g. "next", "prev", "self"). + Rel string + + // Title is an optional human-readable link title. + Title string + + // Type is an optional media type hint for the target. + Type string + + // HrefLang is an optional language hint. + HrefLang string + + // ExtraParams holds additional parameters not covered by the + // fields above. Keys are parameter names, values are parameter + // values. + ExtraParams map[string]string +} + +// Format serializes the Link as an RFC 8288 link value: +// `<uri>; rel="rel"; title="title"`. +func (l Link) Format() string { + var b strings.Builder + b.WriteString("<") + b.WriteString(l.URI) + b.WriteString(">") + + if l.Rel != "" { + b.WriteString(`; rel="`) + b.WriteString(escapeLinkParam(l.Rel)) + b.WriteString(`"`) + } + if l.Title != "" { + b.WriteString(`; title="`) + b.WriteString(escapeLinkParam(l.Title)) + b.WriteString(`"`) + } + if l.Type != "" { + b.WriteString(`; type="`) + b.WriteString(escapeLinkParam(l.Type)) + b.WriteString(`"`) + } + if l.HrefLang != "" { + b.WriteString(`; hreflang="`) + b.WriteString(escapeLinkParam(l.HrefLang)) + b.WriteString(`"`) + } + for k, v := range l.ExtraParams { + b.WriteString(`; `) + b.WriteString(k) + b.WriteString(`="`) + b.WriteString(escapeLinkParam(v)) + b.WriteString(`"`) + } + + return b.String() +} + +// FormatLinkHeader serializes a slice of Links as a single Link header +// value, with links separated by commas. +func FormatLinkHeader(links []Link) string { + parts := make([]string, len(links)) + for i, l := range links { + parts[i] = l.Format() + } + return strings.Join(parts, ", ") +} + +// PaginationLinks holds the rel types for a paginated collection. +type PaginationLinks struct { + First string + Prev string + Next string + Last string + Self string +} + +// PaginationConfig holds the parameters for building pagination links. +type PaginationConfig struct { + // BaseURL is the request URL (absolute or relative) without query + // parameters. May include a path. + BaseURL string + + // Page is the current page number (1-based). + Page int + + // PageSize is the number of items per page. + PageSize int + + // TotalItems is the total number of items across all pages. + // If 0, the "last" link is omitted. + TotalItems int + + // PageParam is the query parameter name for the page number. + // Defaults to "page". + PageParam string + + // PageSizeParam is the query parameter name for the page size. + // Defaults to "per_page". + PageSizeParam string + + // Self is the URI for the "self" link relation. If empty, the + // "self" link is omitted. + Self string + + // ExtraParams holds additional query parameters to preserve + // across pagination links. + ExtraParams url.Values +} + +// BuildPaginationLinks constructs RFC 8288 Link entries for a paginated +// collection. It preserves unrelated query parameters, omits +// unavailable relations (e.g. no "prev" on page 1), and safely handles +// relative or absolute request URLs. +func BuildPaginationLinks(cfg PaginationConfig) []Link { + if cfg.PageParam == "" { + cfg.PageParam = "page" + } + if cfg.PageSizeParam == "" { + cfg.PageSizeParam = "per_page" + } + + totalPages := 0 + if cfg.PageSize > 0 && cfg.TotalItems > 0 { + totalPages = (cfg.TotalItems + cfg.PageSize - 1) / cfg.PageSize + } + + var links []Link + + if cfg.Self != "" { + links = append(links, Link{URI: cfg.Self, Rel: "self"}) + } + + // First page + firstParams := cloneParams(cfg.ExtraParams) + firstParams.Set(cfg.PageParam, "1") + firstParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, firstParams), Rel: "first"}) + + // Previous page (omit on page 1) + if cfg.Page > 1 { + prevParams := cloneParams(cfg.ExtraParams) + prevParams.Set(cfg.PageParam, strconv.Itoa(cfg.Page-1)) + prevParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, prevParams), Rel: "prev"}) + } + + // Next page (omit if no more pages) + if totalPages == 0 || cfg.Page < totalPages { + nextParams := cloneParams(cfg.ExtraParams) + nextParams.Set(cfg.PageParam, strconv.Itoa(cfg.Page+1)) + nextParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, nextParams), Rel: "next"}) + } + + // Last page (omit if total is unknown) + if totalPages > 0 { + lastParams := cloneParams(cfg.ExtraParams) + lastParams.Set(cfg.PageParam, strconv.Itoa(totalPages)) + lastParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, lastParams), Rel: "last"}) + } + + return links +} + +func cloneParams(src url.Values) url.Values { + dst := make(url.Values) + for k, v := range src { + dst[k] = append([]string(nil), v...) + } + return dst +} + +func buildURL(base string, params url.Values) string { + if len(params) == 0 { + return base + } + encoded := params.Encode() + if encoded == "" { + return base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + encoded +} + +func escapeLinkParam(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '\\' || r == '"': + b.WriteRune('\\') + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + b.WriteRune(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// ParseLinkHeader parses an RFC 8288 Link header value into a slice of +// Link entries. This is a simple parser for testing and verification. +func ParseLinkHeader(s string) ([]Link, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + + var links []Link + parts := strings.Split(s, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + link, err := parseSingleLink(part) + if err != nil { + return nil, err + } + links = append(links, link) + } + return links, nil +} + +func parseSingleLink(s string) (Link, error) { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "<") { + return Link{}, fmt.Errorf("httpsemantics: link must start with '<'") + } + closeIdx := strings.Index(s, ">") + if closeIdx < 0 { + return Link{}, fmt.Errorf("httpsemantics: link missing '>'") + } + + link := Link{URI: s[1:closeIdx]} + + rest := strings.TrimSpace(s[closeIdx+1:]) + if rest == "" { + return link, nil + } + + for _, param := range strings.Split(rest, ";") { + param = strings.TrimSpace(param) + if param == "" { + continue + } + eqIdx := strings.Index(param, "=") + if eqIdx < 0 { + continue + } + key := strings.TrimSpace(param[:eqIdx]) + val := strings.TrimSpace(param[eqIdx+1:]) + val = strings.Trim(val, `"`) + switch key { + case "rel": + link.Rel = val + case "title": + link.Title = val + case "type": + link.Type = val + case "hreflang": + link.HrefLang = val + default: + if link.ExtraParams == nil { + link.ExtraParams = make(map[string]string) + } + link.ExtraParams[key] = val + } + } + + return link, nil +} diff --git a/httpsemantics/link_test.go b/httpsemantics/link_test.go new file mode 100644 index 0000000..65d873c --- /dev/null +++ b/httpsemantics/link_test.go @@ -0,0 +1,209 @@ +package httpsemantics + +import ( + "net/url" + "testing" +) + +func TestLinkFormat_Simple(t *testing.T) { + l := Link{URI: "/users", Rel: "self"} + got := l.Format() + want := `</users>; rel="self"` + if got != want { + t.Errorf("Format() = %q, want %q", got, want) + } +} + +func TestLinkFormat_WithTitle(t *testing.T) { + l := Link{URI: "/users", Rel: "next", Title: "Next Page"} + got := l.Format() + want := `</users>; rel="next"; title="Next Page"` + if got != want { + t.Errorf("Format() = %q, want %q", got, want) + } +} + +func TestLinkFormat_WithType(t *testing.T) { + l := Link{URI: "/users.json", Rel: "self", Type: "application/json"} + got := l.Format() + if got != `</users.json>; rel="self"; type="application/json"` { + t.Errorf("Format() = %q", got) + } +} + +func TestFormatLinkHeader_Multiple(t *testing.T) { + links := []Link{ + {URI: "/page/1", Rel: "first"}, + {URI: "/page/2", Rel: "next"}, + {URI: "/page/10", Rel: "last"}, + } + got := FormatLinkHeader(links) + want := `</page/1>; rel="first", </page/2>; rel="next", </page/10>; rel="last"` + if got != want { + t.Errorf("FormatLinkHeader() = %q, want %q", got, want) + } +} + +func TestBuildPaginationLinks_MiddlePage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 3, + PageSize: 10, + TotalItems: 50, + }) + // Should have: self (omitted since Self is empty), first, prev, next, last + // Actually self is omitted when empty + if len(links) != 4 { + t.Fatalf("len = %d, want 4 (first, prev, next, last)", len(links)) + } + rels := []string{links[0].Rel, links[1].Rel, links[2].Rel, links[3].Rel} + expected := []string{"first", "prev", "next", "last"} + for i, want := range expected { + if rels[i] != want { + t.Errorf("links[%d].Rel = %q, want %q", i, rels[i], want) + } + } +} + +func TestBuildPaginationLinks_FirstPage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 50, + }) + // Should have: first, next, last (no prev) + if len(links) != 3 { + t.Fatalf("len = %d, want 3 (first, next, last)", len(links)) + } + for _, l := range links { + if l.Rel == "prev" { + t.Error("prev should not be present on page 1") + } + } +} + +func TestBuildPaginationLinks_LastPage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 5, + PageSize: 10, + TotalItems: 50, + }) + // Should have: first, prev, last (no next) + for _, l := range links { + if l.Rel == "next" { + t.Error("next should not be present on last page") + } + } +} + +func TestBuildPaginationLinks_UnknownTotal(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 0, + }) + // Should have: first, next (no last since total is unknown) + for _, l := range links { + if l.Rel == "last" { + t.Error("last should not be present when total is unknown") + } + } +} + +func TestBuildPaginationLinks_PreservesExtraParams(t *testing.T) { + extra := url.Values{} + extra.Set("sort", "name") + extra.Set("filter", "active") + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 20, + ExtraParams: extra, + }) + for _, l := range links { + if l.Rel == "next" { + parsed, err := url.Parse(l.URI) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Query().Get("sort") != "name" { + t.Errorf("sort = %q, want %q", parsed.Query().Get("sort"), "name") + } + if parsed.Query().Get("filter") != "active" { + t.Errorf("filter = %q, want %q", parsed.Query().Get("filter"), "active") + } + } + } +} + +func TestBuildPaginationLinks_CustomParamNames(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 2, + PageSize: 20, + TotalItems: 100, + PageParam: "p", + PageSizeParam: "size", + }) + for _, l := range links { + if l.Rel == "first" { + parsed, err := url.Parse(l.URI) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Query().Get("p") != "1" { + t.Errorf("p = %q, want %q", parsed.Query().Get("p"), "1") + } + if parsed.Query().Get("size") != "20" { + t.Errorf("size = %q, want %q", parsed.Query().Get("size"), "20") + } + } + } +} + +func TestParseLinkHeader_Simple(t *testing.T) { + links, err := ParseLinkHeader(`</users>; rel="self"`) + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if len(links) != 1 { + t.Fatalf("len = %d, want 1", len(links)) + } + if links[0].URI != "/users" { + t.Errorf("URI = %q, want %q", links[0].URI, "/users") + } + if links[0].Rel != "self" { + t.Errorf("Rel = %q, want %q", links[0].Rel, "self") + } +} + +func TestParseLinkHeader_Multiple(t *testing.T) { + links, err := ParseLinkHeader(`</page/1>; rel="first", </page/2>; rel="next"`) + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if len(links) != 2 { + t.Fatalf("len = %d, want 2", len(links)) + } +} + +func TestParseLinkHeader_Empty(t *testing.T) { + links, err := ParseLinkHeader("") + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if links != nil { + t.Errorf("links = %v, want nil", links) + } +} + +func TestEscapeLinkParam_PreventsInjection(t *testing.T) { + got := escapeLinkParam("evil\r\nX-Injected: yes") + if got != "evil X-Injected: yes" { + t.Errorf("escapeLinkParam() = %q, want %q", got, "evil X-Injected: yes") + } +} diff --git a/httpsemantics/retry_after.go b/httpsemantics/retry_after.go new file mode 100644 index 0000000..b7ec4fb --- /dev/null +++ b/httpsemantics/retry_after.go @@ -0,0 +1,102 @@ +package httpsemantics + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// RetryAfter represents a parsed RFC 9110 Retry-After header value. +// It is either a delta-seconds (non-negative integer) or an HTTP-date. +type RetryAfter struct { + // Delta is the delay in seconds. Valid only when IsDate is false. + Delta int + + // Date is the retry time. Valid only when IsDate is true. + Date time.Time + + // IsDate indicates whether the value is an HTTP-date form. + IsDate bool +} + +// Duration returns the effective delay duration. For delta-seconds, +// it returns Delta seconds. For HTTP-date, it returns the duration +// from now until the date (clamped to non-negative). The clock +// parameter allows injecting a time source for deterministic tests. +func (r RetryAfter) Duration(now time.Time) time.Duration { + if r.IsDate { + d := r.Date.Sub(now) + if d < 0 { + return 0 + } + return d + } + return time.Duration(r.Delta) * time.Second +} + +// ParseRetryAfter parses an RFC 9110 Retry-After header value. Supports +// both delta-seconds (integer) and HTTP-date (RFC 7231 IMF-fixdate) +// forms. Returns an error for malformed input. +func ParseRetryAfter(s string) (RetryAfter, error) { + s = strings.TrimSpace(s) + if s == "" { + return RetryAfter{}, fmt.Errorf("httpsemantics: empty Retry-After") + } + + // Try delta-seconds first (most common) + if delta, err := strconv.Atoi(s); err == nil { + if delta < 0 { + return RetryAfter{}, fmt.Errorf("httpsemantics: negative Retry-After: %d", delta) + } + return RetryAfter{Delta: delta}, nil + } + + // Try HTTP-date + t, err := http.ParseTime(s) + if err != nil { + return RetryAfter{}, fmt.Errorf("httpsemantics: invalid Retry-After: %q", s) + } + + return RetryAfter{Date: t, IsDate: true}, nil +} + +// FormatRetryAfterDelta formats a delta-seconds value as an RFC 9110 +// Retry-After header string. +func FormatRetryAfterDelta(seconds int) string { + return strconv.Itoa(seconds) +} + +// FormatRetryAfterDate formats a time.Time as an RFC 9110 Retry-After +// HTTP-date header string (RFC 7231 IMF-fixdate format). +func FormatRetryAfterDate(t time.Time) string { + return t.UTC().Format(http.TimeFormat) +} + +// ClampRetryAfter caps a parsed Retry-After duration to a maximum +// delay. This prevents denial-of-service via excessively large +// Retry-After values. Returns the clamped duration in seconds. +func ClampRetryAfter(ra RetryAfter, now time.Time, maxDelay time.Duration) int { + d := ra.Duration(now) + if maxDelay > 0 && d > maxDelay { + d = maxDelay + } + return int(d.Seconds()) +} + +// FormatRetryAfter formats a parsed RetryAfter as a header string, +// clamped to maxDelay seconds. If maxDelay is 0, no clamping is applied. +func FormatRetryAfter(ra RetryAfter, now time.Time, maxDelay time.Duration) string { + if ra.IsDate { + d := ra.Duration(now) + if maxDelay > 0 && d > maxDelay { + return FormatRetryAfterDelta(int(maxDelay.Seconds())) + } + return FormatRetryAfterDate(ra.Date) + } + if maxDelay > 0 && time.Duration(ra.Delta)*time.Second > maxDelay { + return FormatRetryAfterDelta(int(maxDelay.Seconds())) + } + return FormatRetryAfterDelta(ra.Delta) +} diff --git a/httpsemantics/retry_after_test.go b/httpsemantics/retry_after_test.go new file mode 100644 index 0000000..47ca1eb --- /dev/null +++ b/httpsemantics/retry_after_test.go @@ -0,0 +1,177 @@ +package httpsemantics + +import ( + "net/http" + "testing" + "time" +) + +func TestParseRetryAfter_DeltaSeconds(t *testing.T) { + ra, err := ParseRetryAfter("120") + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if ra.IsDate { + t.Error("IsDate = true, want false") + } + if ra.Delta != 120 { + t.Errorf("Delta = %d, want 120", ra.Delta) + } +} + +func TestParseRetryAfter_Zero(t *testing.T) { + ra, err := ParseRetryAfter("0") + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if ra.Delta != 0 { + t.Errorf("Delta = %d, want 0", ra.Delta) + } +} + +func TestParseRetryAfter_HTTPDate(t *testing.T) { + dateStr := "Tue, 21 Oct 2025 07:28:00 GMT" + ra, err := ParseRetryAfter(dateStr) + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if !ra.IsDate { + t.Error("IsDate = false, want true") + } + expected, _ := http.ParseTime(dateStr) + if !ra.Date.Equal(expected) { + t.Errorf("Date = %v, want %v", ra.Date, expected) + } +} + +func TestParseRetryAfter_Empty(t *testing.T) { + _, err := ParseRetryAfter("") + if err == nil { + t.Error("ParseRetryAfter(\"\") should error") + } +} + +func TestParseRetryAfter_Negative(t *testing.T) { + _, err := ParseRetryAfter("-1") + if err == nil { + t.Error("ParseRetryAfter(\"-1\") should error") + } +} + +func TestParseRetryAfter_Malformed(t *testing.T) { + _, err := ParseRetryAfter("not a date or number") + if err == nil { + t.Error("ParseRetryAfter with malformed input should error") + } +} + +func TestRetryAfterDuration_Delta(t *testing.T) { + ra := RetryAfter{Delta: 60} + now := time.Now() + d := ra.Duration(now) + if d != 60*time.Second { + t.Errorf("Duration() = %v, want 60s", d) + } +} + +func TestRetryAfterDuration_Date_Future(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + future := now.Add(2 * time.Minute) + ra := RetryAfter{Date: future, IsDate: true} + d := ra.Duration(now) + if d != 2*time.Minute { + t.Errorf("Duration() = %v, want 2m", d) + } +} + +func TestRetryAfterDuration_Date_Past(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + past := now.Add(-1 * time.Minute) + ra := RetryAfter{Date: past, IsDate: true} + d := ra.Duration(now) + if d != 0 { + t.Errorf("Duration() = %v, want 0 (past date clamped)", d) + } +} + +func TestFormatRetryAfterDelta(t *testing.T) { + if got := FormatRetryAfterDelta(120); got != "120" { + t.Errorf("FormatRetryAfterDelta() = %q, want %q", got, "120") + } +} + +func TestFormatRetryAfterDate(t *testing.T) { + tm := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + got := FormatRetryAfterDate(tm) + want := "Tue, 21 Oct 2025 07:28:00 GMT" + if got != want { + t.Errorf("FormatRetryAfterDate() = %q, want %q", got, want) + } +} + +func TestClampRetryAfter_Delta(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 60*time.Second) + if clamped != 60 { + t.Errorf("ClampRetryAfter() = %d, want 60", clamped) + } +} + +func TestClampRetryAfter_Delta_UnderMax(t *testing.T) { + ra := RetryAfter{Delta: 30} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 60*time.Second) + if clamped != 30 { + t.Errorf("ClampRetryAfter() = %d, want 30", clamped) + } +} + +func TestClampRetryAfter_NoMax(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 0) + if clamped != 600 { + t.Errorf("ClampRetryAfter() = %d, want 600 (no clamp)", clamped) + } +} + +func TestFormatRetryAfter_Delta(t *testing.T) { + ra := RetryAfter{Delta: 120} + now := time.Now() + got := FormatRetryAfter(ra, now, 0) + if got != "120" { + t.Errorf("FormatRetryAfter() = %q, want %q", got, "120") + } +} + +func TestFormatRetryAfter_Delta_Clamped(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + got := FormatRetryAfter(ra, now, 60*time.Second) + if got != "60" { + t.Errorf("FormatRetryAfter() = %q, want %q (clamped)", got, "60") + } +} + +func TestFormatRetryAfter_Date(t *testing.T) { + tm := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + ra := RetryAfter{Date: tm, IsDate: true} + now := time.Date(2025, 10, 21, 7, 27, 0, 0, time.UTC) + got := FormatRetryAfter(ra, now, 0) + want := "Tue, 21 Oct 2025 07:28:00 GMT" + if got != want { + t.Errorf("FormatRetryAfter() = %q, want %q", got, want) + } +} + +func TestFormatRetryAfter_Date_Clamped(t *testing.T) { + // Date is 1 hour in the future, but max is 60 seconds + tm := time.Date(2025, 10, 21, 8, 28, 0, 0, time.UTC) + ra := RetryAfter{Date: tm, IsDate: true} + now := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + got := FormatRetryAfter(ra, now, 60*time.Second) + if got != "60" { + t.Errorf("FormatRetryAfter() = %q, want %q (clamped)", got, "60") + } +} diff --git a/httpsemantics/trace.go b/httpsemantics/trace.go new file mode 100644 index 0000000..c6d881a --- /dev/null +++ b/httpsemantics/trace.go @@ -0,0 +1,69 @@ +package httpsemantics + +import ( + "context" + "net/http" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +// ExtractW3CTraceContext extracts W3C Trace Context headers (traceparent, +// tracestate) from the given HTTP request and returns a new context with +// the trace context embedded. This is the inbound extraction point for +// W3C trace propagation. +// +// This helper uses the globally configured OpenTelemetry propagator +// (otel.GetTextMapPropagator()), which is typically a composite of +// propagation.TraceContext{} and propagation.Baggage{} as configured +// by libTracing. +// +// Usage in framework adapters: +// +// ctx := httpsemantics.ExtractW3CTraceContext(r.Context(), r.Header) +// // Pass ctx to request.NewContext or use it directly +func ExtractW3CTraceContext(parent context.Context, h http.Header) context.Context { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return parent + } + return propagator.Extract(parent, propagation.HeaderCarrier(h)) +} + +// InjectW3CTraceContext injects the W3C Trace Context headers from the +// given context into the provided HTTP headers. This is the outbound +// injection point for W3C trace propagation when making downstream +// API calls. +// +// Usage: +// +// httpsemantics.InjectW3CTraceContext(ctx, req.Header) +// // Then send req via http.Client +func InjectW3CTraceContext(ctx context.Context, h http.Header) { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return + } + propagator.Inject(ctx, propagation.HeaderCarrier(h)) +} + +// ExtractW3CTraceContextFromMap extracts W3C Trace Context headers from +// a map[string]string (e.g. framework parser header map) and returns a +// new context with the trace context embedded. +func ExtractW3CTraceContextFromMap(parent context.Context, headers map[string]string) context.Context { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return parent + } + return propagator.Extract(parent, propagation.MapCarrier(headers)) +} + +// InjectW3CTraceContextToMap injects W3C Trace Context headers from the +// given context into a map[string]string. +func InjectW3CTraceContextToMap(ctx context.Context, headers map[string]string) { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return + } + propagator.Inject(ctx, propagation.MapCarrier(headers)) +} diff --git a/httpsemantics/trace_test.go b/httpsemantics/trace_test.go new file mode 100644 index 0000000..a004c57 --- /dev/null +++ b/httpsemantics/trace_test.go @@ -0,0 +1,109 @@ +package httpsemantics + +import ( + "context" + "net/http" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/trace" + oteltrace "go.opentelemetry.io/otel/trace" +) + +func TestInjectExtractW3CTraceContext_RoundTrip(t *testing.T) { + // Set up the W3C TraceContext propagator + otel.SetTextMapPropagator(propagation.TraceContext{}) + defer otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // Create a real tracer with a valid span context + tp := trace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + defer otel.SetTracerProvider(trace.NewTracerProvider()) // restore noop + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + h := http.Header{} + InjectW3CTraceContext(ctx, h) + + // Verify traceparent header was injected + tpHeader := h.Get("traceparent") + if tpHeader == "" { + t.Fatal("traceparent header not injected") + } + + // Now extract it back + extractedCtx := ExtractW3CTraceContext(context.Background(), h) + extractedSpan := oteltrace.SpanFromContext(extractedCtx) + if !extractedSpan.SpanContext().IsValid() { + t.Fatal("extracted span context is not valid") + } + + // The extracted trace ID should match the original + originalTraceID := span.SpanContext().TraceID() + extractedTraceID := extractedSpan.SpanContext().TraceID() + if originalTraceID != extractedTraceID { + t.Errorf("trace ID mismatch: original=%s extracted=%s", originalTraceID, extractedTraceID) + } +} + +func TestExtractW3CTraceContext_NoPropagator(t *testing.T) { + // Save and restore the global propagator + saved := otel.GetTextMapPropagator() + defer otel.SetTextMapPropagator(saved) + + // Set a no-op propagator (empty composite) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator()) + + h := http.Header{} + h.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + + ctx := ExtractW3CTraceContext(context.Background(), h) + // With a no-op propagator, the context should not have a valid span + span := oteltrace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + t.Error("expected invalid span context with no-op propagator") + } +} + +func TestInjectW3CTraceContextToMap_RoundTrip(t *testing.T) { + otel.SetTextMapPropagator(propagation.TraceContext{}) + defer otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + tp := trace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + defer otel.SetTracerProvider(trace.NewTracerProvider()) // restore noop + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + headers := make(map[string]string) + InjectW3CTraceContextToMap(ctx, headers) + + if headers["traceparent"] == "" { + t.Fatal("traceparent not injected to map") + } + + extractedCtx := ExtractW3CTraceContextFromMap(context.Background(), headers) + extractedSpan := oteltrace.SpanFromContext(extractedCtx) + if !extractedSpan.SpanContext().IsValid() { + t.Fatal("extracted span context is not valid") + } + + originalTraceID := span.SpanContext().TraceID() + extractedTraceID := extractedSpan.SpanContext().TraceID() + if originalTraceID != extractedTraceID { + t.Errorf("trace ID mismatch: original=%s extracted=%s", originalTraceID, extractedTraceID) + } +} diff --git a/libCallApi/builder.go b/libCallApi/builder.go index b2e8099..ef14eef 100644 --- a/libCallApi/builder.go +++ b/libCallApi/builder.go @@ -19,10 +19,15 @@ import ( func StatusPreservingBuilder[Resp any](statusCode int, rawResp []byte, headers map[string]string) (*Resp, error) { if statusCode < 200 || statusCode >= 300 { _, innerErr := DefaultBuilderfunc[Resp](statusCode, rawResp, headers) + hdr := make(http.Header) + for k, v := range headers { + hdr.Set(k, v) + } return nil, &RemoteCallError{ - Status: statusCode, - Body: rawResp, - Err: innerErr, + Status: statusCode, + Body: rawResp, + Headers: hdr, + Err: innerErr, } } if statusCode == http.StatusNoContent || len(rawResp) == 0 { diff --git a/libCallApi/errors.go b/libCallApi/errors.go index 4f5ffa2..21b01a2 100644 --- a/libCallApi/errors.go +++ b/libCallApi/errors.go @@ -10,9 +10,10 @@ import ( // from a non-2xx remote API call. It implements error and Unwrap so that callers // can use errors.As to extract the status code and body for routing decisions. type RemoteCallError struct { - Status int // HTTP status code from the remote response - Body []byte // Raw response body for debugging - Err error // Underlying error (e.g. libError.NewWithDescription) + Status int // HTTP status code from the remote response + Body []byte // Raw response body for debugging + Headers http.Header // Non-sensitive response headers (e.g. Retry-After) + Err error // Underlying error (e.g. libError.NewWithDescription) } // Error returns a human-readable description of the remote call error. diff --git a/libRetry/retry.go b/libRetry/retry.go index 4688c8a..4590f69 100644 --- a/libRetry/retry.go +++ b/libRetry/retry.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/hmmftg/requestCore/httpsemantics" "github.com/hmmftg/requestCore/libCallApi" "github.com/hmmftg/requestCore/libError" ) @@ -107,6 +108,26 @@ type RetryPolicy struct { // a timer-based implementation is used that exits early on context // cancellation. Useful for deterministic testing. Sleep func(ctx context.Context, d time.Duration) bool + + // HonorRetryAfter enables honoring the Retry-After header from + // 429 (Too Many Requests) and 503 (Service Unavailable) responses. + // When enabled, the retry loop parses the Retry-After header from + // the RemoteCallError's Headers and waits for the indicated + // duration (clamped to MaxRetryAfterDelay) instead of using the + // fixed Backoff. If the header is absent, the fixed Backoff is + // used. Default is false (fixed backoff only). + HonorRetryAfter bool + + // MaxRetryAfterDelay caps the delay applied when honoring + // Retry-After headers. If 0, no cap is applied. This prevents + // denial-of-service via excessively large Retry-After values. + // Typical values: 30s to 5m. + MaxRetryAfterDelay time.Duration + + // Now is an optional function returning the current time, used + // for Retry-After HTTP-date evaluation. If nil, time.Now is used. + // Useful for deterministic testing. + Now func() time.Time } // RetryResult holds the outcome of a retry sequence. @@ -196,7 +217,13 @@ func WithRetry[Resp any](policy *RetryPolicy, attempt AttemptFunc[Resp]) RetryRe if err == nil { // Success — but check if the response indicates a retryable status if shouldRetryResponse(policy, resp, status) && attemptNum < maxAttempts { - if !sleepFn(ctx, policy.Backoff) { + delay := policy.Backoff + if policy.HonorRetryAfter { + if d, ok := extractRetryAfterDelay(policy, nil); ok { + delay = d + } + } + if !sleepFn(ctx, delay) { // Context cancelled during backoff result.Error = ctx.Err() return result @@ -216,7 +243,13 @@ func WithRetry[Resp any](policy *RetryPolicy, attempt AttemptFunc[Resp]) RetryRe } // Backoff before next attempt - if !sleepFn(ctx, policy.Backoff) { + delay := policy.Backoff + if policy.HonorRetryAfter { + if d, ok := extractRetryAfterDelay(policy, err); ok { + delay = d + } + } + if !sleepFn(ctx, delay) { result.Error = ctx.Err() return result } @@ -295,3 +328,46 @@ func FormatAttemptTitle(base string, attempt int) string { } return fmt.Sprintf("%s-retry-%d", base, attempt-1) } + +// extractRetryAfterDelay attempts to extract a Retry-After delay from +// an error (via RemoteCallError.Headers). Returns the delay and true +// if a valid Retry-After was found; otherwise returns 0 and false. +// +// The delay is clamped to MaxRetryAfterDelay if non-zero. +func extractRetryAfterDelay(policy *RetryPolicy, err error) (time.Duration, bool) { + var headers http.Header + + // Try to get headers from RemoteCallError + if err != nil { + var rce *libCallApi.RemoteCallError + if errors.As(err, &rce) && rce.Headers != nil { + headers = rce.Headers + } + } + + if headers == nil { + return 0, false + } + + retryAfterStr := headers.Get("Retry-After") + if retryAfterStr == "" { + return 0, false + } + + ra, err := httpsemantics.ParseRetryAfter(retryAfterStr) + if err != nil { + return 0, false + } + + now := time.Now() + if policy.Now != nil { + now = policy.Now() + } + + delay := ra.Duration(now) + if policy.MaxRetryAfterDelay > 0 && delay > policy.MaxRetryAfterDelay { + delay = policy.MaxRetryAfterDelay + } + + return delay, true +} diff --git a/libRetry/retry_test.go b/libRetry/retry_test.go index 59b3937..1a8c600 100644 --- a/libRetry/retry_test.go +++ b/libRetry/retry_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sync" "sync/atomic" "testing" "time" @@ -472,3 +473,176 @@ func TestDeriveStatusCode(t *testing.T) { }) } } + +func TestWithRetry_HonorRetryAfter_DeltaSeconds(t *testing.T) { + var sleepDurations []time.Duration + var mu sync.Mutex + + policy := &libRetry.RetryPolicy{ + MaxRetries: 1, + RetryOnStatus: map[int]bool{503: true}, + HonorRetryAfter: true, + MaxRetryAfterDelay: 60 * time.Second, + Backoff: 1 * time.Millisecond, + Sleep: func(_ context.Context, d time.Duration) bool { + mu.Lock() + sleepDurations = append(sleepDurations, d) + mu.Unlock() + return true + }, + } + + result := libRetry.WithRetry(policy, func(attempt int) (*testResp, int, error) { + if attempt == 1 { + h := make(http.Header) + h.Set("Retry-After", "5") + return nil, 503, &libCallApi.RemoteCallError{ + Status: 503, + Headers: h, + Err: errors.New("503"), + } + } + return &testResp{Data: "ok"}, 200, nil + }) + + assert.NilError(t, result.Error) + assert.Equal(t, result.Attempts, 2) + + mu.Lock() + defer mu.Unlock() + if len(sleepDurations) != 1 { + t.Fatalf("expected 1 sleep call, got %d", len(sleepDurations)) + } + // Retry-After: 5 should cause a 5-second delay, not the 1ms backoff + if sleepDurations[0] != 5*time.Second { + t.Errorf("expected 5s delay from Retry-After, got %v", sleepDurations[0]) + } +} + +func TestWithRetry_HonorRetryAfter_Clamped(t *testing.T) { + var sleepDurations []time.Duration + var mu sync.Mutex + + policy := &libRetry.RetryPolicy{ + MaxRetries: 1, + RetryOnStatus: map[int]bool{503: true}, + HonorRetryAfter: true, + MaxRetryAfterDelay: 10 * time.Second, + Backoff: 1 * time.Millisecond, + Sleep: func(_ context.Context, d time.Duration) bool { + mu.Lock() + sleepDurations = append(sleepDurations, d) + mu.Unlock() + return true + }, + } + + result := libRetry.WithRetry(policy, func(attempt int) (*testResp, int, error) { + if attempt == 1 { + h := make(http.Header) + h.Set("Retry-After", "3600") // 1 hour, should be clamped to 10s + return nil, 503, &libCallApi.RemoteCallError{ + Status: 503, + Headers: h, + Err: errors.New("503"), + } + } + return &testResp{Data: "ok"}, 200, nil + }) + + assert.NilError(t, result.Error) + + mu.Lock() + defer mu.Unlock() + if len(sleepDurations) != 1 { + t.Fatalf("expected 1 sleep call, got %d", len(sleepDurations)) + } + // Should be clamped to MaxRetryAfterDelay (10s), not 3600s + if sleepDurations[0] != 10*time.Second { + t.Errorf("expected 10s (clamped), got %v", sleepDurations[0]) + } +} + +func TestWithRetry_HonorRetryAfter_Disabled(t *testing.T) { + var sleepDurations []time.Duration + var mu sync.Mutex + + policy := &libRetry.RetryPolicy{ + MaxRetries: 1, + RetryOnStatus: map[int]bool{503: true}, + HonorRetryAfter: false, // disabled + Backoff: 5 * time.Millisecond, + Sleep: func(_ context.Context, d time.Duration) bool { + mu.Lock() + sleepDurations = append(sleepDurations, d) + mu.Unlock() + return true + }, + } + + result := libRetry.WithRetry(policy, func(attempt int) (*testResp, int, error) { + if attempt == 1 { + h := make(http.Header) + h.Set("Retry-After", "60") + return nil, 503, &libCallApi.RemoteCallError{ + Status: 503, + Headers: h, + Err: errors.New("503"), + } + } + return &testResp{Data: "ok"}, 200, nil + }) + + assert.NilError(t, result.Error) + + mu.Lock() + defer mu.Unlock() + if len(sleepDurations) != 1 { + t.Fatalf("expected 1 sleep call, got %d", len(sleepDurations)) + } + // Without HonorRetryAfter, the fixed Backoff (5ms) should be used + if sleepDurations[0] != 5*time.Millisecond { + t.Errorf("expected 5ms (fixed backoff), got %v", sleepDurations[0]) + } +} + +func TestWithRetry_HonorRetryAfter_NoHeader(t *testing.T) { + var sleepDurations []time.Duration + var mu sync.Mutex + + policy := &libRetry.RetryPolicy{ + MaxRetries: 1, + RetryOnStatus: map[int]bool{503: true}, + HonorRetryAfter: true, + Backoff: 5 * time.Millisecond, + Sleep: func(_ context.Context, d time.Duration) bool { + mu.Lock() + sleepDurations = append(sleepDurations, d) + mu.Unlock() + return true + }, + } + + result := libRetry.WithRetry(policy, func(attempt int) (*testResp, int, error) { + if attempt == 1 { + // No Retry-After header + return nil, 503, &libCallApi.RemoteCallError{ + Status: 503, + Err: errors.New("503"), + } + } + return &testResp{Data: "ok"}, 200, nil + }) + + assert.NilError(t, result.Error) + + mu.Lock() + defer mu.Unlock() + if len(sleepDurations) != 1 { + t.Fatalf("expected 1 sleep call, got %d", len(sleepDurations)) + } + // Without Retry-After header, the fixed Backoff (5ms) should be used + if sleepDurations[0] != 5*time.Millisecond { + t.Errorf("expected 5ms (fixed backoff, no Retry-After), got %v", sleepDurations[0]) + } +} diff --git a/v2/httpsemantics/conditional.go b/v2/httpsemantics/conditional.go new file mode 100644 index 0000000..5a4acba --- /dev/null +++ b/v2/httpsemantics/conditional.go @@ -0,0 +1,246 @@ +package httpsemantics + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// ETag represents an RFC 9110 entity tag with optional weak indicator. +type ETag struct { + // Weak indicates whether this is a weak entity tag (W/ prefix). + Weak bool + + // Value is the opaque entity-tag value without the surrounding + // double quotes or W/ prefix. + Value string +} + +// String formats the ETag as an RFC 9110 entity-tag value: +// `W/"value"` for weak, `"value"` for strong. +func (e ETag) String() string { + if e.Weak { + return fmt.Sprintf(`W/"%s"`, e.Value) + } + return fmt.Sprintf(`"%s"`, e.Value) +} + +// StrongEqual reports whether two ETags are strongly equal per +// RFC 9110 §8.8.3.2: both must be strong and their values must match. +func (e ETag) StrongEqual(other ETag) bool { + return !e.Weak && !other.Weak && e.Value == other.Value +} + +// WeakEqual reports whether two ETags are weakly equal per +// RFC 9110 §8.8.3.2: their values must match, regardless of weakness. +func (e ETag) WeakEqual(other ETag) bool { + return e.Value == other.Value +} + +// ParseETag parses a single RFC 9110 entity-tag value. Supports both +// strong ("value") and weak (W/"value") forms. Returns an error for +// malformed input. +func ParseETag(s string) (ETag, error) { + s = strings.TrimSpace(s) + if s == "" { + return ETag{}, fmt.Errorf("httpsemantics: empty entity tag") + } + + weak := false + if strings.HasPrefix(s, "W/") { + weak = true + s = s[2:] + } + + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return ETag{}, fmt.Errorf("httpsemantics: malformed entity tag: %q", s) + } + + value := s[1 : len(s)-1] + if strings.ContainsAny(value, `"`) { + return ETag{}, fmt.Errorf("httpsemantics: unescaped quote in entity tag: %q", s) + } + + return ETag{Weak: weak, Value: value}, nil +} + +// ParseETagList parses a comma-separated list of entity tags from a +// header value (e.g. If-Match, If-None-Match). Returns the parsed +// ETags. The wildcard "*" is represented as ETag{Value: "*"}. +func ParseETagList(s string) ([]ETag, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + + if s == "*" { + return []ETag{{Value: "*"}}, nil + } + + parts := strings.Split(s, ",") + tags := make([]ETag, 0, len(parts)) + for _, part := range parts { + tag, err := ParseETag(part) + if err != nil { + return nil, err + } + tags = append(tags, tag) + } + return tags, nil +} + +// FormatETagList formats a slice of ETags as a comma-separated header +// value. +func FormatETagList(tags []ETag) string { + parts := make([]string, len(tags)) + for i, t := range tags { + parts[i] = t.String() + } + return strings.Join(parts, ", ") +} + +// PreconditionResult indicates the outcome of precondition evaluation. +type PreconditionResult int + +const ( + // PreconditionProceed means the request should proceed normally. + PreconditionProceed PreconditionResult = iota + + // PreconditionNotModified means the server should respond with 304 + // Not Modified. + PreconditionNotModified + + // PreconditionFailed means the server should respond with 412 + // Precondition Failed. + PreconditionFailed + + // PreconditionRequired means the server should respond with 428 + // Precondition Required (the resource requires a precondition + // that was not provided). + PreconditionRequired +) + +// PreconditionInput holds the request headers and resource state needed +// to evaluate RFC 9110 preconditions. +type PreconditionInput struct { + // IfMatch is the value of the If-Match header. Empty if absent. + IfMatch string + + // IfNoneMatch is the value of the If-None-Match header. Empty if absent. + IfNoneMatch string + + // IfModifiedSince is the value of the If-Modified-Since header. + // Empty if absent. + IfModifiedSince string + + // IfUnmodifiedSince is the value of the If-Unmodified-Since header. + // Empty if absent. + IfUnmodifiedSince string + + // ResourceETag is the current entity tag of the resource. + // Empty if the resource has no ETag. + ResourceETag string + + // ResourceModified is the last modification time of the resource. + // Zero if the resource has no Last-Modified. + ResourceModified time.Time + + // IsSafeMethod is true for GET and HEAD (where If-None-Match can + // produce 304 rather than 412). + IsSafeMethod bool +} + +// EvaluatePreconditions evaluates RFC 9110 §13.1.2 precondition headers +// in precedence order and returns the appropriate result. +// +// Precedence (RFC 9110 §13.2.2): +// 1. If-Match +// 2. If-Unmodified-Since +// 3. If-None-Match +// 4. If-Modified-Since +// +// If-Match and If-Unmodified-Since produce 412 on failure. +// If-None-Match and If-Modified-Since produce 304 on failure for safe +// methods, 412 for unsafe methods. +func EvaluatePreconditions(in PreconditionInput) PreconditionResult { + // 1. If-Match + if in.IfMatch != "" { + tags, err := ParseETagList(in.IfMatch) + if err != nil { + return PreconditionFailed + } + if !matchETag(tags, in.ResourceETag) { + return PreconditionFailed + } + } + + // 2. If-Unmodified-Since + if in.IfUnmodifiedSince != "" { + since, err := http.ParseTime(in.IfUnmodifiedSince) + if err != nil { + return PreconditionFailed + } + if !in.ResourceModified.IsZero() && in.ResourceModified.After(since) { + return PreconditionFailed + } + } + + // 3. If-None-Match + if in.IfNoneMatch != "" { + tags, err := ParseETagList(in.IfNoneMatch) + if err != nil { + return PreconditionFailed + } + if matchETag(tags, in.ResourceETag) { + if in.IsSafeMethod { + return PreconditionNotModified + } + return PreconditionFailed + } + } + + // 4. If-Modified-Since + if in.IfModifiedSince != "" { + since, err := http.ParseTime(in.IfModifiedSince) + if err != nil { + return PreconditionFailed + } + if in.IsSafeMethod && !in.ResourceModified.IsZero() && !in.ResourceModified.After(since) { + return PreconditionNotModified + } + } + + return PreconditionProceed +} + +// matchETag checks whether the resource ETag matches any tag in the +// provided list. The wildcard "*" matches any non-empty resource ETag. +func matchETag(tags []ETag, resourceETag string) bool { + if resourceETag == "" { + return false + } + + resourceTag, err := ParseETag(resourceETag) + if err != nil { + return false + } + + for _, tag := range tags { + if tag.Value == "*" { + return true + } + if tag.WeakEqual(resourceTag) { + return true + } + } + return false +} + +// IsNoBodyStatus reports whether the given HTTP status code should not +// have a response body per RFC 9110. +func IsNoBodyStatus(status int) bool { + return status == http.StatusNoContent || + status == http.StatusResetContent || + status == http.StatusNotModified +} diff --git a/v2/httpsemantics/conditional_test.go b/v2/httpsemantics/conditional_test.go new file mode 100644 index 0000000..526cfa5 --- /dev/null +++ b/v2/httpsemantics/conditional_test.go @@ -0,0 +1,260 @@ +package httpsemantics + +import ( + "net/http" + "testing" + "time" +) + +func TestETagString_Strong(t *testing.T) { + e := ETag{Value: "abc123"} + if got := e.String(); got != `"abc123"` { + t.Errorf("String() = %q, want %q", got, `"abc123"`) + } +} + +func TestETagString_Weak(t *testing.T) { + e := ETag{Weak: true, Value: "abc123"} + if got := e.String(); got != `W/"abc123"` { + t.Errorf("String() = %q, want %q", got, `W/"abc123"`) + } +} + +func TestParseETag_Strong(t *testing.T) { + e, err := ParseETag(`"abc123"`) + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if e.Weak { + t.Error("Weak = true, want false") + } + if e.Value != "abc123" { + t.Errorf("Value = %q, want %q", e.Value, "abc123") + } +} + +func TestParseETag_Weak(t *testing.T) { + e, err := ParseETag(`W/"abc123"`) + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if !e.Weak { + t.Error("Weak = false, want true") + } + if e.Value != "abc123" { + t.Errorf("Value = %q, want %q", e.Value, "abc123") + } +} + +func TestParseETag_Empty(t *testing.T) { + _, err := ParseETag("") + if err == nil { + t.Error("ParseETag(\"\") should error") + } +} + +func TestParseETag_Malformed(t *testing.T) { + _, err := ParseETag("abc123") + if err == nil { + t.Error("ParseETag(\"abc123\") should error") + } +} + +func TestParseETag_UnescapedQuote(t *testing.T) { + _, err := ParseETag(`"ab"c"`) + if err == nil { + t.Error("ParseETag with unescaped quote should error") + } +} + +func TestParseETagList_Single(t *testing.T) { + tags, err := ParseETagList(`"abc"`) + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 1 { + t.Fatalf("len = %d, want 1", len(tags)) + } +} + +func TestParseETagList_Multiple(t *testing.T) { + tags, err := ParseETagList(`"abc", "def", W/"ghi"`) + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 3 { + t.Fatalf("len = %d, want 3", len(tags)) + } + if tags[2].Value != "ghi" || !tags[2].Weak { + t.Errorf("tags[2] = %+v, want weak ghi", tags[2]) + } +} + +func TestParseETagList_Wildcard(t *testing.T) { + tags, err := ParseETagList("*") + if err != nil { + t.Fatalf("ParseETagList() error = %v", err) + } + if len(tags) != 1 || tags[0].Value != "*" { + t.Fatalf("tags = %+v, want wildcard", tags) + } +} + +func TestETagStrongEqual(t *testing.T) { + a := ETag{Value: "abc"} + b := ETag{Value: "abc"} + if !a.StrongEqual(b) { + t.Error("StrongEqual should be true for matching strong tags") + } + c := ETag{Weak: true, Value: "abc"} + if a.StrongEqual(c) { + t.Error("StrongEqual should be false when one is weak") + } +} + +func TestETagWeakEqual(t *testing.T) { + a := ETag{Value: "abc"} + b := ETag{Weak: true, Value: "abc"} + if !a.WeakEqual(b) { + t.Error("WeakEqual should be true regardless of weakness") + } +} + +func TestEvaluatePreconditions_IfMatch_Match(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_NoMatch(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_Wildcard(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `*`, + ResourceETag: `"anything"`, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfMatch_Wildcard_NoResource(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `*`, + ResourceETag: "", + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfNoneMatch_SafeMethod_304(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + }) + if result != PreconditionNotModified { + t.Errorf("result = %v, want PreconditionNotModified", result) + } +} + +func TestEvaluatePreconditions_IfNoneMatch_UnsafeMethod_412(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: false, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_IfModifiedSince_NotModified(t *testing.T) { + modTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfModifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionNotModified { + t.Errorf("result = %v, want PreconditionNotModified", result) + } +} + +func TestEvaluatePreconditions_IfModifiedSince_Modified(t *testing.T) { + modTime := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfModifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_IfUnmodifiedSince_Modified(t *testing.T) { + modTime := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).Format(http.TimeFormat) + result := EvaluatePreconditions(PreconditionInput{ + IfUnmodifiedSince: since, + ResourceModified: modTime, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed", result) + } +} + +func TestEvaluatePreconditions_NoPreconditions(t *testing.T) { + result := EvaluatePreconditions(PreconditionInput{ + IsSafeMethod: true, + }) + if result != PreconditionProceed { + t.Errorf("result = %v, want PreconditionProceed", result) + } +} + +func TestEvaluatePreconditions_Precedence_IfMatchBeforeIfNoneMatch(t *testing.T) { + // If-Match fails, If-None-Match would succeed. If-Match should win. + result := EvaluatePreconditions(PreconditionInput{ + IfMatch: `"abc"`, + IfNoneMatch: `"def"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + }) + if result != PreconditionFailed { + t.Errorf("result = %v, want PreconditionFailed (If-Match takes precedence)", result) + } +} + +func TestIsNoBodyStatus(t *testing.T) { + if !IsNoBodyStatus(http.StatusNoContent) { + t.Error("204 should be no-body") + } + if !IsNoBodyStatus(http.StatusNotModified) { + t.Error("304 should be no-body") + } + if IsNoBodyStatus(http.StatusOK) { + t.Error("200 should not be no-body") + } +} diff --git a/v2/httpsemantics/link.go b/v2/httpsemantics/link.go new file mode 100644 index 0000000..54c4b71 --- /dev/null +++ b/v2/httpsemantics/link.go @@ -0,0 +1,291 @@ +package httpsemantics + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// Link represents a single RFC 8288 Web Link with a target URI and +// optional parameters. +type Link struct { + // URI is the link target. May be relative or absolute. + URI string + + // Rel is the link relation type (e.g. "next", "prev", "self"). + Rel string + + // Title is an optional human-readable link title. + Title string + + // Type is an optional media type hint for the target. + Type string + + // HrefLang is an optional language hint. + HrefLang string + + // ExtraParams holds additional parameters not covered by the + // fields above. Keys are parameter names, values are parameter + // values. + ExtraParams map[string]string +} + +// Format serializes the Link as an RFC 8288 link value: +// `<uri>; rel="rel"; title="title"`. +func (l Link) Format() string { + var b strings.Builder + b.WriteString("<") + b.WriteString(l.URI) + b.WriteString(">") + + if l.Rel != "" { + b.WriteString(`; rel="`) + b.WriteString(escapeLinkParam(l.Rel)) + b.WriteString(`"`) + } + if l.Title != "" { + b.WriteString(`; title="`) + b.WriteString(escapeLinkParam(l.Title)) + b.WriteString(`"`) + } + if l.Type != "" { + b.WriteString(`; type="`) + b.WriteString(escapeLinkParam(l.Type)) + b.WriteString(`"`) + } + if l.HrefLang != "" { + b.WriteString(`; hreflang="`) + b.WriteString(escapeLinkParam(l.HrefLang)) + b.WriteString(`"`) + } + for k, v := range l.ExtraParams { + b.WriteString(`; `) + b.WriteString(k) + b.WriteString(`="`) + b.WriteString(escapeLinkParam(v)) + b.WriteString(`"`) + } + + return b.String() +} + +// FormatLinkHeader serializes a slice of Links as a single Link header +// value, with links separated by commas. +func FormatLinkHeader(links []Link) string { + parts := make([]string, len(links)) + for i, l := range links { + parts[i] = l.Format() + } + return strings.Join(parts, ", ") +} + +// PaginationLinks holds the rel types for a paginated collection. +type PaginationLinks struct { + First string + Prev string + Next string + Last string + Self string +} + +// PaginationConfig holds the parameters for building pagination links. +type PaginationConfig struct { + // BaseURL is the request URL (absolute or relative) without query + // parameters. May include a path. + BaseURL string + + // Page is the current page number (1-based). + Page int + + // PageSize is the number of items per page. + PageSize int + + // TotalItems is the total number of items across all pages. + // If 0, the "last" link is omitted. + TotalItems int + + // PageParam is the query parameter name for the page number. + // Defaults to "page". + PageParam string + + // PageSizeParam is the query parameter name for the page size. + // Defaults to "per_page". + PageSizeParam string + + // Self is the URI for the "self" link relation. If empty, the + // "self" link is omitted. + Self string + + // ExtraParams holds additional query parameters to preserve + // across pagination links. + ExtraParams url.Values +} + +// BuildPaginationLinks constructs RFC 8288 Link entries for a paginated +// collection. It preserves unrelated query parameters, omits +// unavailable relations (e.g. no "prev" on page 1), and safely handles +// relative or absolute request URLs. +func BuildPaginationLinks(cfg PaginationConfig) []Link { + if cfg.PageParam == "" { + cfg.PageParam = "page" + } + if cfg.PageSizeParam == "" { + cfg.PageSizeParam = "per_page" + } + + totalPages := 0 + if cfg.PageSize > 0 && cfg.TotalItems > 0 { + totalPages = (cfg.TotalItems + cfg.PageSize - 1) / cfg.PageSize + } + + var links []Link + + if cfg.Self != "" { + links = append(links, Link{URI: cfg.Self, Rel: "self"}) + } + + // First page + firstParams := cloneParams(cfg.ExtraParams) + firstParams.Set(cfg.PageParam, "1") + firstParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, firstParams), Rel: "first"}) + + // Previous page (omit on page 1) + if cfg.Page > 1 { + prevParams := cloneParams(cfg.ExtraParams) + prevParams.Set(cfg.PageParam, strconv.Itoa(cfg.Page-1)) + prevParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, prevParams), Rel: "prev"}) + } + + // Next page (omit if no more pages) + if totalPages == 0 || cfg.Page < totalPages { + nextParams := cloneParams(cfg.ExtraParams) + nextParams.Set(cfg.PageParam, strconv.Itoa(cfg.Page+1)) + nextParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, nextParams), Rel: "next"}) + } + + // Last page (omit if total is unknown) + if totalPages > 0 { + lastParams := cloneParams(cfg.ExtraParams) + lastParams.Set(cfg.PageParam, strconv.Itoa(totalPages)) + lastParams.Set(cfg.PageSizeParam, strconv.Itoa(cfg.PageSize)) + links = append(links, Link{URI: buildURL(cfg.BaseURL, lastParams), Rel: "last"}) + } + + return links +} + +func cloneParams(src url.Values) url.Values { + dst := make(url.Values) + for k, v := range src { + dst[k] = append([]string(nil), v...) + } + return dst +} + +func buildURL(base string, params url.Values) string { + if len(params) == 0 { + return base + } + encoded := params.Encode() + if encoded == "" { + return base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + encoded +} + +func escapeLinkParam(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == '\\' || r == '"': + b.WriteRune('\\') + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + b.WriteRune(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// ParseLinkHeader parses an RFC 8288 Link header value into a slice of +// Link entries. This is a simple parser for testing and verification. +func ParseLinkHeader(s string) ([]Link, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + + var links []Link + parts := strings.Split(s, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + link, err := parseSingleLink(part) + if err != nil { + return nil, err + } + links = append(links, link) + } + return links, nil +} + +func parseSingleLink(s string) (Link, error) { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "<") { + return Link{}, fmt.Errorf("httpsemantics: link must start with '<'") + } + closeIdx := strings.Index(s, ">") + if closeIdx < 0 { + return Link{}, fmt.Errorf("httpsemantics: link missing '>'") + } + + link := Link{URI: s[1:closeIdx]} + + rest := strings.TrimSpace(s[closeIdx+1:]) + if rest == "" { + return link, nil + } + + for _, param := range strings.Split(rest, ";") { + param = strings.TrimSpace(param) + if param == "" { + continue + } + eqIdx := strings.Index(param, "=") + if eqIdx < 0 { + continue + } + key := strings.TrimSpace(param[:eqIdx]) + val := strings.TrimSpace(param[eqIdx+1:]) + val = strings.Trim(val, `"`) + switch key { + case "rel": + link.Rel = val + case "title": + link.Title = val + case "type": + link.Type = val + case "hreflang": + link.HrefLang = val + default: + if link.ExtraParams == nil { + link.ExtraParams = make(map[string]string) + } + link.ExtraParams[key] = val + } + } + + return link, nil +} diff --git a/v2/httpsemantics/link_test.go b/v2/httpsemantics/link_test.go new file mode 100644 index 0000000..65d873c --- /dev/null +++ b/v2/httpsemantics/link_test.go @@ -0,0 +1,209 @@ +package httpsemantics + +import ( + "net/url" + "testing" +) + +func TestLinkFormat_Simple(t *testing.T) { + l := Link{URI: "/users", Rel: "self"} + got := l.Format() + want := `</users>; rel="self"` + if got != want { + t.Errorf("Format() = %q, want %q", got, want) + } +} + +func TestLinkFormat_WithTitle(t *testing.T) { + l := Link{URI: "/users", Rel: "next", Title: "Next Page"} + got := l.Format() + want := `</users>; rel="next"; title="Next Page"` + if got != want { + t.Errorf("Format() = %q, want %q", got, want) + } +} + +func TestLinkFormat_WithType(t *testing.T) { + l := Link{URI: "/users.json", Rel: "self", Type: "application/json"} + got := l.Format() + if got != `</users.json>; rel="self"; type="application/json"` { + t.Errorf("Format() = %q", got) + } +} + +func TestFormatLinkHeader_Multiple(t *testing.T) { + links := []Link{ + {URI: "/page/1", Rel: "first"}, + {URI: "/page/2", Rel: "next"}, + {URI: "/page/10", Rel: "last"}, + } + got := FormatLinkHeader(links) + want := `</page/1>; rel="first", </page/2>; rel="next", </page/10>; rel="last"` + if got != want { + t.Errorf("FormatLinkHeader() = %q, want %q", got, want) + } +} + +func TestBuildPaginationLinks_MiddlePage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 3, + PageSize: 10, + TotalItems: 50, + }) + // Should have: self (omitted since Self is empty), first, prev, next, last + // Actually self is omitted when empty + if len(links) != 4 { + t.Fatalf("len = %d, want 4 (first, prev, next, last)", len(links)) + } + rels := []string{links[0].Rel, links[1].Rel, links[2].Rel, links[3].Rel} + expected := []string{"first", "prev", "next", "last"} + for i, want := range expected { + if rels[i] != want { + t.Errorf("links[%d].Rel = %q, want %q", i, rels[i], want) + } + } +} + +func TestBuildPaginationLinks_FirstPage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 50, + }) + // Should have: first, next, last (no prev) + if len(links) != 3 { + t.Fatalf("len = %d, want 3 (first, next, last)", len(links)) + } + for _, l := range links { + if l.Rel == "prev" { + t.Error("prev should not be present on page 1") + } + } +} + +func TestBuildPaginationLinks_LastPage(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 5, + PageSize: 10, + TotalItems: 50, + }) + // Should have: first, prev, last (no next) + for _, l := range links { + if l.Rel == "next" { + t.Error("next should not be present on last page") + } + } +} + +func TestBuildPaginationLinks_UnknownTotal(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 0, + }) + // Should have: first, next (no last since total is unknown) + for _, l := range links { + if l.Rel == "last" { + t.Error("last should not be present when total is unknown") + } + } +} + +func TestBuildPaginationLinks_PreservesExtraParams(t *testing.T) { + extra := url.Values{} + extra.Set("sort", "name") + extra.Set("filter", "active") + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 1, + PageSize: 10, + TotalItems: 20, + ExtraParams: extra, + }) + for _, l := range links { + if l.Rel == "next" { + parsed, err := url.Parse(l.URI) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Query().Get("sort") != "name" { + t.Errorf("sort = %q, want %q", parsed.Query().Get("sort"), "name") + } + if parsed.Query().Get("filter") != "active" { + t.Errorf("filter = %q, want %q", parsed.Query().Get("filter"), "active") + } + } + } +} + +func TestBuildPaginationLinks_CustomParamNames(t *testing.T) { + links := BuildPaginationLinks(PaginationConfig{ + BaseURL: "/api/users", + Page: 2, + PageSize: 20, + TotalItems: 100, + PageParam: "p", + PageSizeParam: "size", + }) + for _, l := range links { + if l.Rel == "first" { + parsed, err := url.Parse(l.URI) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Query().Get("p") != "1" { + t.Errorf("p = %q, want %q", parsed.Query().Get("p"), "1") + } + if parsed.Query().Get("size") != "20" { + t.Errorf("size = %q, want %q", parsed.Query().Get("size"), "20") + } + } + } +} + +func TestParseLinkHeader_Simple(t *testing.T) { + links, err := ParseLinkHeader(`</users>; rel="self"`) + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if len(links) != 1 { + t.Fatalf("len = %d, want 1", len(links)) + } + if links[0].URI != "/users" { + t.Errorf("URI = %q, want %q", links[0].URI, "/users") + } + if links[0].Rel != "self" { + t.Errorf("Rel = %q, want %q", links[0].Rel, "self") + } +} + +func TestParseLinkHeader_Multiple(t *testing.T) { + links, err := ParseLinkHeader(`</page/1>; rel="first", </page/2>; rel="next"`) + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if len(links) != 2 { + t.Fatalf("len = %d, want 2", len(links)) + } +} + +func TestParseLinkHeader_Empty(t *testing.T) { + links, err := ParseLinkHeader("") + if err != nil { + t.Fatalf("ParseLinkHeader() error = %v", err) + } + if links != nil { + t.Errorf("links = %v, want nil", links) + } +} + +func TestEscapeLinkParam_PreventsInjection(t *testing.T) { + got := escapeLinkParam("evil\r\nX-Injected: yes") + if got != "evil X-Injected: yes" { + t.Errorf("escapeLinkParam() = %q, want %q", got, "evil X-Injected: yes") + } +} diff --git a/v2/httpsemantics/retry_after.go b/v2/httpsemantics/retry_after.go new file mode 100644 index 0000000..b7ec4fb --- /dev/null +++ b/v2/httpsemantics/retry_after.go @@ -0,0 +1,102 @@ +package httpsemantics + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// RetryAfter represents a parsed RFC 9110 Retry-After header value. +// It is either a delta-seconds (non-negative integer) or an HTTP-date. +type RetryAfter struct { + // Delta is the delay in seconds. Valid only when IsDate is false. + Delta int + + // Date is the retry time. Valid only when IsDate is true. + Date time.Time + + // IsDate indicates whether the value is an HTTP-date form. + IsDate bool +} + +// Duration returns the effective delay duration. For delta-seconds, +// it returns Delta seconds. For HTTP-date, it returns the duration +// from now until the date (clamped to non-negative). The clock +// parameter allows injecting a time source for deterministic tests. +func (r RetryAfter) Duration(now time.Time) time.Duration { + if r.IsDate { + d := r.Date.Sub(now) + if d < 0 { + return 0 + } + return d + } + return time.Duration(r.Delta) * time.Second +} + +// ParseRetryAfter parses an RFC 9110 Retry-After header value. Supports +// both delta-seconds (integer) and HTTP-date (RFC 7231 IMF-fixdate) +// forms. Returns an error for malformed input. +func ParseRetryAfter(s string) (RetryAfter, error) { + s = strings.TrimSpace(s) + if s == "" { + return RetryAfter{}, fmt.Errorf("httpsemantics: empty Retry-After") + } + + // Try delta-seconds first (most common) + if delta, err := strconv.Atoi(s); err == nil { + if delta < 0 { + return RetryAfter{}, fmt.Errorf("httpsemantics: negative Retry-After: %d", delta) + } + return RetryAfter{Delta: delta}, nil + } + + // Try HTTP-date + t, err := http.ParseTime(s) + if err != nil { + return RetryAfter{}, fmt.Errorf("httpsemantics: invalid Retry-After: %q", s) + } + + return RetryAfter{Date: t, IsDate: true}, nil +} + +// FormatRetryAfterDelta formats a delta-seconds value as an RFC 9110 +// Retry-After header string. +func FormatRetryAfterDelta(seconds int) string { + return strconv.Itoa(seconds) +} + +// FormatRetryAfterDate formats a time.Time as an RFC 9110 Retry-After +// HTTP-date header string (RFC 7231 IMF-fixdate format). +func FormatRetryAfterDate(t time.Time) string { + return t.UTC().Format(http.TimeFormat) +} + +// ClampRetryAfter caps a parsed Retry-After duration to a maximum +// delay. This prevents denial-of-service via excessively large +// Retry-After values. Returns the clamped duration in seconds. +func ClampRetryAfter(ra RetryAfter, now time.Time, maxDelay time.Duration) int { + d := ra.Duration(now) + if maxDelay > 0 && d > maxDelay { + d = maxDelay + } + return int(d.Seconds()) +} + +// FormatRetryAfter formats a parsed RetryAfter as a header string, +// clamped to maxDelay seconds. If maxDelay is 0, no clamping is applied. +func FormatRetryAfter(ra RetryAfter, now time.Time, maxDelay time.Duration) string { + if ra.IsDate { + d := ra.Duration(now) + if maxDelay > 0 && d > maxDelay { + return FormatRetryAfterDelta(int(maxDelay.Seconds())) + } + return FormatRetryAfterDate(ra.Date) + } + if maxDelay > 0 && time.Duration(ra.Delta)*time.Second > maxDelay { + return FormatRetryAfterDelta(int(maxDelay.Seconds())) + } + return FormatRetryAfterDelta(ra.Delta) +} diff --git a/v2/httpsemantics/retry_after_test.go b/v2/httpsemantics/retry_after_test.go new file mode 100644 index 0000000..47ca1eb --- /dev/null +++ b/v2/httpsemantics/retry_after_test.go @@ -0,0 +1,177 @@ +package httpsemantics + +import ( + "net/http" + "testing" + "time" +) + +func TestParseRetryAfter_DeltaSeconds(t *testing.T) { + ra, err := ParseRetryAfter("120") + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if ra.IsDate { + t.Error("IsDate = true, want false") + } + if ra.Delta != 120 { + t.Errorf("Delta = %d, want 120", ra.Delta) + } +} + +func TestParseRetryAfter_Zero(t *testing.T) { + ra, err := ParseRetryAfter("0") + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if ra.Delta != 0 { + t.Errorf("Delta = %d, want 0", ra.Delta) + } +} + +func TestParseRetryAfter_HTTPDate(t *testing.T) { + dateStr := "Tue, 21 Oct 2025 07:28:00 GMT" + ra, err := ParseRetryAfter(dateStr) + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if !ra.IsDate { + t.Error("IsDate = false, want true") + } + expected, _ := http.ParseTime(dateStr) + if !ra.Date.Equal(expected) { + t.Errorf("Date = %v, want %v", ra.Date, expected) + } +} + +func TestParseRetryAfter_Empty(t *testing.T) { + _, err := ParseRetryAfter("") + if err == nil { + t.Error("ParseRetryAfter(\"\") should error") + } +} + +func TestParseRetryAfter_Negative(t *testing.T) { + _, err := ParseRetryAfter("-1") + if err == nil { + t.Error("ParseRetryAfter(\"-1\") should error") + } +} + +func TestParseRetryAfter_Malformed(t *testing.T) { + _, err := ParseRetryAfter("not a date or number") + if err == nil { + t.Error("ParseRetryAfter with malformed input should error") + } +} + +func TestRetryAfterDuration_Delta(t *testing.T) { + ra := RetryAfter{Delta: 60} + now := time.Now() + d := ra.Duration(now) + if d != 60*time.Second { + t.Errorf("Duration() = %v, want 60s", d) + } +} + +func TestRetryAfterDuration_Date_Future(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + future := now.Add(2 * time.Minute) + ra := RetryAfter{Date: future, IsDate: true} + d := ra.Duration(now) + if d != 2*time.Minute { + t.Errorf("Duration() = %v, want 2m", d) + } +} + +func TestRetryAfterDuration_Date_Past(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + past := now.Add(-1 * time.Minute) + ra := RetryAfter{Date: past, IsDate: true} + d := ra.Duration(now) + if d != 0 { + t.Errorf("Duration() = %v, want 0 (past date clamped)", d) + } +} + +func TestFormatRetryAfterDelta(t *testing.T) { + if got := FormatRetryAfterDelta(120); got != "120" { + t.Errorf("FormatRetryAfterDelta() = %q, want %q", got, "120") + } +} + +func TestFormatRetryAfterDate(t *testing.T) { + tm := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + got := FormatRetryAfterDate(tm) + want := "Tue, 21 Oct 2025 07:28:00 GMT" + if got != want { + t.Errorf("FormatRetryAfterDate() = %q, want %q", got, want) + } +} + +func TestClampRetryAfter_Delta(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 60*time.Second) + if clamped != 60 { + t.Errorf("ClampRetryAfter() = %d, want 60", clamped) + } +} + +func TestClampRetryAfter_Delta_UnderMax(t *testing.T) { + ra := RetryAfter{Delta: 30} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 60*time.Second) + if clamped != 30 { + t.Errorf("ClampRetryAfter() = %d, want 30", clamped) + } +} + +func TestClampRetryAfter_NoMax(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + clamped := ClampRetryAfter(ra, now, 0) + if clamped != 600 { + t.Errorf("ClampRetryAfter() = %d, want 600 (no clamp)", clamped) + } +} + +func TestFormatRetryAfter_Delta(t *testing.T) { + ra := RetryAfter{Delta: 120} + now := time.Now() + got := FormatRetryAfter(ra, now, 0) + if got != "120" { + t.Errorf("FormatRetryAfter() = %q, want %q", got, "120") + } +} + +func TestFormatRetryAfter_Delta_Clamped(t *testing.T) { + ra := RetryAfter{Delta: 600} + now := time.Now() + got := FormatRetryAfter(ra, now, 60*time.Second) + if got != "60" { + t.Errorf("FormatRetryAfter() = %q, want %q (clamped)", got, "60") + } +} + +func TestFormatRetryAfter_Date(t *testing.T) { + tm := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + ra := RetryAfter{Date: tm, IsDate: true} + now := time.Date(2025, 10, 21, 7, 27, 0, 0, time.UTC) + got := FormatRetryAfter(ra, now, 0) + want := "Tue, 21 Oct 2025 07:28:00 GMT" + if got != want { + t.Errorf("FormatRetryAfter() = %q, want %q", got, want) + } +} + +func TestFormatRetryAfter_Date_Clamped(t *testing.T) { + // Date is 1 hour in the future, but max is 60 seconds + tm := time.Date(2025, 10, 21, 8, 28, 0, 0, time.UTC) + ra := RetryAfter{Date: tm, IsDate: true} + now := time.Date(2025, 10, 21, 7, 28, 0, 0, time.UTC) + got := FormatRetryAfter(ra, now, 60*time.Second) + if got != "60" { + t.Errorf("FormatRetryAfter() = %q, want %q (clamped)", got, "60") + } +} diff --git a/v2/httpsemantics/trace.go b/v2/httpsemantics/trace.go new file mode 100644 index 0000000..c6d881a --- /dev/null +++ b/v2/httpsemantics/trace.go @@ -0,0 +1,69 @@ +package httpsemantics + +import ( + "context" + "net/http" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +// ExtractW3CTraceContext extracts W3C Trace Context headers (traceparent, +// tracestate) from the given HTTP request and returns a new context with +// the trace context embedded. This is the inbound extraction point for +// W3C trace propagation. +// +// This helper uses the globally configured OpenTelemetry propagator +// (otel.GetTextMapPropagator()), which is typically a composite of +// propagation.TraceContext{} and propagation.Baggage{} as configured +// by libTracing. +// +// Usage in framework adapters: +// +// ctx := httpsemantics.ExtractW3CTraceContext(r.Context(), r.Header) +// // Pass ctx to request.NewContext or use it directly +func ExtractW3CTraceContext(parent context.Context, h http.Header) context.Context { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return parent + } + return propagator.Extract(parent, propagation.HeaderCarrier(h)) +} + +// InjectW3CTraceContext injects the W3C Trace Context headers from the +// given context into the provided HTTP headers. This is the outbound +// injection point for W3C trace propagation when making downstream +// API calls. +// +// Usage: +// +// httpsemantics.InjectW3CTraceContext(ctx, req.Header) +// // Then send req via http.Client +func InjectW3CTraceContext(ctx context.Context, h http.Header) { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return + } + propagator.Inject(ctx, propagation.HeaderCarrier(h)) +} + +// ExtractW3CTraceContextFromMap extracts W3C Trace Context headers from +// a map[string]string (e.g. framework parser header map) and returns a +// new context with the trace context embedded. +func ExtractW3CTraceContextFromMap(parent context.Context, headers map[string]string) context.Context { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return parent + } + return propagator.Extract(parent, propagation.MapCarrier(headers)) +} + +// InjectW3CTraceContextToMap injects W3C Trace Context headers from the +// given context into a map[string]string. +func InjectW3CTraceContextToMap(ctx context.Context, headers map[string]string) { + propagator := otel.GetTextMapPropagator() + if propagator == nil { + return + } + propagator.Inject(ctx, propagation.MapCarrier(headers)) +} diff --git a/v2/httpsemantics/trace_test.go b/v2/httpsemantics/trace_test.go new file mode 100644 index 0000000..a004c57 --- /dev/null +++ b/v2/httpsemantics/trace_test.go @@ -0,0 +1,109 @@ +package httpsemantics + +import ( + "context" + "net/http" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/trace" + oteltrace "go.opentelemetry.io/otel/trace" +) + +func TestInjectExtractW3CTraceContext_RoundTrip(t *testing.T) { + // Set up the W3C TraceContext propagator + otel.SetTextMapPropagator(propagation.TraceContext{}) + defer otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // Create a real tracer with a valid span context + tp := trace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + defer otel.SetTracerProvider(trace.NewTracerProvider()) // restore noop + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + h := http.Header{} + InjectW3CTraceContext(ctx, h) + + // Verify traceparent header was injected + tpHeader := h.Get("traceparent") + if tpHeader == "" { + t.Fatal("traceparent header not injected") + } + + // Now extract it back + extractedCtx := ExtractW3CTraceContext(context.Background(), h) + extractedSpan := oteltrace.SpanFromContext(extractedCtx) + if !extractedSpan.SpanContext().IsValid() { + t.Fatal("extracted span context is not valid") + } + + // The extracted trace ID should match the original + originalTraceID := span.SpanContext().TraceID() + extractedTraceID := extractedSpan.SpanContext().TraceID() + if originalTraceID != extractedTraceID { + t.Errorf("trace ID mismatch: original=%s extracted=%s", originalTraceID, extractedTraceID) + } +} + +func TestExtractW3CTraceContext_NoPropagator(t *testing.T) { + // Save and restore the global propagator + saved := otel.GetTextMapPropagator() + defer otel.SetTextMapPropagator(saved) + + // Set a no-op propagator (empty composite) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator()) + + h := http.Header{} + h.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + + ctx := ExtractW3CTraceContext(context.Background(), h) + // With a no-op propagator, the context should not have a valid span + span := oteltrace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + t.Error("expected invalid span context with no-op propagator") + } +} + +func TestInjectW3CTraceContextToMap_RoundTrip(t *testing.T) { + otel.SetTextMapPropagator(propagation.TraceContext{}) + defer otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + tp := trace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + defer otel.SetTracerProvider(trace.NewTracerProvider()) // restore noop + + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + headers := make(map[string]string) + InjectW3CTraceContextToMap(ctx, headers) + + if headers["traceparent"] == "" { + t.Fatal("traceparent not injected to map") + } + + extractedCtx := ExtractW3CTraceContextFromMap(context.Background(), headers) + extractedSpan := oteltrace.SpanFromContext(extractedCtx) + if !extractedSpan.SpanContext().IsValid() { + t.Fatal("extracted span context is not valid") + } + + originalTraceID := span.SpanContext().TraceID() + extractedTraceID := extractedSpan.SpanContext().TraceID() + if originalTraceID != extractedTraceID { + t.Errorf("trace ID mismatch: original=%s extracted=%s", originalTraceID, extractedTraceID) + } +} From 032ed5c93b83c777c00fc900625c163ce9e52f60 Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi <h.malekmohammadi@stts.ir> Date: Tue, 8 Sep 2026 14:47:17 +0330 Subject: [PATCH 4/5] =?UTF-8?q?feat(idempotency):=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20idempotency=20contracts=20and=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add idempotency package with reusable contracts for HTTP idempotency key handling: key validation (format, length, printable ASCII only), request fingerprinting (deterministic SHA-256 of method, path, body hash), Store interface for application-owned durable storage, Record lifecycle (in-progress, completed, failed), SafeHeaders to strip sensitive headers before storage, and IsSafeMethod/RequiresIdempotencyKey helpers. Add MemoryStore for testing and single-process development with race-safe concurrent reservation. Never expose raw idempotency keys in telemetry or response bodies. Application retains ownership of durable storage, transaction boundaries, and replay policy. Mirror all contracts in v2/idempotency. Race tests verify concurrent reservation produces exactly one success and N-1 conflicts. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- idempotency/idempotency.go | 256 +++++++++++++++++++ idempotency/idempotency_test.go | 397 +++++++++++++++++++++++++++++ idempotency/memory_store.go | 143 +++++++++++ v2/idempotency/idempotency.go | 256 +++++++++++++++++++ v2/idempotency/idempotency_test.go | 397 +++++++++++++++++++++++++++++ v2/idempotency/memory_store.go | 143 +++++++++++ 6 files changed, 1592 insertions(+) create mode 100644 idempotency/idempotency.go create mode 100644 idempotency/idempotency_test.go create mode 100644 idempotency/memory_store.go create mode 100644 v2/idempotency/idempotency.go create mode 100644 v2/idempotency/idempotency_test.go create mode 100644 v2/idempotency/memory_store.go diff --git a/idempotency/idempotency.go b/idempotency/idempotency.go new file mode 100644 index 0000000..9d03581 --- /dev/null +++ b/idempotency/idempotency.go @@ -0,0 +1,256 @@ +// Package idempotency provides reusable contracts for HTTP idempotency +// key handling, request fingerprinting, and replay-safe response +// capture. The package defines interfaces and helpers that allow +// applications to implement durable idempotency storage while the +// reusable library provides validation, fingerprinting, and safe +// response capture. +// +// The application retains ownership of: +// - Durable storage (Redis, database, etc.) +// - Transaction boundaries +// - Replay policy (TTL, eviction) +// - Concurrency control implementation +// +// The reusable library provides: +// - Key validation (format, length, allowed characters) +// - Request fingerprinting (deterministic hash of method, path, body) +// - Response capture contracts (status, headers, body) +// - Safe replay (never expose raw keys, never replay partial writes) +// - Conflict detection interfaces +package idempotency + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +// KeyMaxLength is the maximum allowed length for an idempotency key. +// This prevents abuse via excessively long keys. +const KeyMaxLength = 255 + +// KeyMinLength is the minimum allowed length for an idempotency key. +const KeyMinLength = 1 + +// HeaderName is the standard HTTP header for idempotency keys. +const HeaderName = "Idempotency-Key" + +// ErrInvalidKey indicates the idempotency key is malformed. +var ErrInvalidKey = errors.New("idempotency: invalid key") + +// ErrKeyTooLong indicates the idempotency key exceeds the maximum length. +var ErrKeyTooLong = errors.New("idempotency: key too long") + +// ErrKeyEmpty indicates the idempotency key is empty. +var ErrKeyEmpty = errors.New("idempotency: empty key") + +// ErrFingerprintMismatch indicates the request fingerprint does not +// match the stored fingerprint for the given idempotency key. +var ErrFingerprintMismatch = errors.New("idempotency: fingerprint mismatch") + +// ValidateKey validates an idempotency key. The key must be non-empty, +// no longer than KeyMaxLength, and contain only printable ASCII +// characters (no control characters, no whitespace-only). This +// prevents injection and abuse. +func ValidateKey(key string) error { + if key == "" { + return ErrKeyEmpty + } + if len(key) > KeyMaxLength { + return ErrKeyTooLong + } + for _, r := range key { + if r < 0x20 || r > 0x7e { + return fmt.Errorf("%w: contains non-printable character", ErrInvalidKey) + } + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("%w: whitespace-only key", ErrInvalidKey) + } + return nil +} + +// RequestFingerprint is a deterministic hash of the request method, +// path, and body. It is used to detect mismatched requests reusing the +// same idempotency key. +type RequestFingerprint struct { + // Method is the HTTP method (uppercase). + Method string + + // Path is the request path (including query string). + Path string + + // BodyHash is the SHA-256 hash of the request body (hex-encoded). + BodyHash string +} + +// Hash returns a deterministic hash of the fingerprint, suitable for +// use as a storage key or comparison value. +func (f RequestFingerprint) Hash() string { + h := sha256.New() + h.Write([]byte(f.Method)) + h.Write([]byte{0}) + h.Write([]byte(f.Path)) + h.Write([]byte{0}) + h.Write([]byte(f.BodyHash)) + return hex.EncodeToString(h.Sum(nil)) +} + +// FingerprintRequest creates a RequestFingerprint from the HTTP method, +// path, and body. The body is hashed (SHA-256) so the fingerprint does +// not retain the raw body. +func FingerprintRequest(method, path string, body []byte) RequestFingerprint { + bodyHash := sha256.Sum256(body) + return RequestFingerprint{ + Method: strings.ToUpper(method), + Path: path, + BodyHash: hex.EncodeToString(bodyHash[:]), + } +} + +// RecordState represents the lifecycle state of an idempotency record. +type RecordState int + +const ( + // StateInProgress indicates the request is being processed. + // A concurrent request with the same key should receive 409 Conflict. + StateInProgress RecordState = iota + + // StateCompleted indicates the request finished successfully and + // the stored response should be replayed. + StateCompleted + + // StateFailed indicates the request failed and should not be + // replayed (the client should retry with the same key). + StateFailed +) + +// Record represents a stored idempotency record. The application is +// responsible for persisting this; the reusable library defines the +// contract. +type Record struct { + // Key is the idempotency key (already validated). + Key string + + // Fingerprint is the request fingerprint. + Fingerprint RequestFingerprint + + // State is the current lifecycle state. + State RecordState + + // ResponseStatus is the HTTP status of the completed response. + // Valid only when State == StateCompleted. + ResponseStatus int + + // ResponseHeaders are the non-sensitive response headers to replay. + // Sensitive headers (Set-Cookie, Authorization) should be filtered + // by the application before storage. + ResponseHeaders http.Header + + // ResponseBody is the response body to replay. + // Valid only when State == StateCompleted. + ResponseBody []byte + + // CreatedAt is when the record was created. + CreatedAt time.Time + + // ExpiresAt is when the record should be evicted. + ExpiresAt time.Time +} + +// IsExpired reports whether the record has expired relative to now. +func (r *Record) IsExpired(now time.Time) bool { + return !r.ExpiresAt.IsZero() && now.After(r.ExpiresAt) +} + +// Store is the contract for idempotency record storage. The application +// implements this interface using its preferred durable storage +// (Redis, database, etc.). The reusable library does not provide a +// production Store implementation. +// +// All methods must be safe for concurrent use. The Reserve operation +// must be atomic to prevent races. +type Store interface { + // Reserve attempts to create an in-progress record for the given + // key and fingerprint. If a record already exists: + // - If it is in-progress, return the existing record and + // ErrConflict. + // - If it is completed, return the existing record and + // ErrReplayAvailable. + // - If it is expired, the implementation should evict it and + // allow the reservation. + // - If the fingerprint does not match, return + // ErrFingerprintMismatch. + Reserve(key string, fingerprint RequestFingerprint, ttl time.Duration) (existing *Record, err error) + + // Complete marks an in-progress record as completed with the + // given response. The response headers and body are stored for + // replay. Sensitive headers must be filtered by the caller before + // storage. + Complete(key string, status int, headers http.Header, body []byte) error + + // Fail marks an in-progress record as failed. The record may be + // retried by the client with the same key. + Fail(key string) error + + // Get retrieves the record for the given key. Returns nil, nil if + // the key does not exist. + Get(key string) (*Record, error) +} + +// Sentinel errors returned by Store implementations. +var ( + // ErrConflict indicates a record is in-progress for the given key. + ErrConflict = errors.New("idempotency: conflict (in-progress)") + + // ErrReplayAvailable indicates a completed record is available + // for replay. + ErrReplayAvailable = errors.New("idempotency: replay available") +) + +// SafeHeaders returns a copy of the given headers with sensitive +// headers removed. This should be called before storing response +// headers in an idempotency record to prevent replaying credentials +// or session tokens. +func SafeHeaders(h http.Header) http.Header { + safe := make(http.Header) + for k, vs := range h { + switch strings.ToLower(k) { + case "set-cookie", "authorization", "cookie", + "www-authenticate", "proxy-authenticate", + "proxy-authorization": + continue + default: + safe[k] = append([]string(nil), vs...) + } + } + return safe +} + +// IsSafeMethod reports whether the HTTP method is idempotent by +// default (GET, HEAD, PUT, DELETE). POST and PATCH require an +// idempotency key to be safely retried. +func IsSafeMethod(method string) bool { + switch strings.ToUpper(method) { + case "GET", "HEAD", "PUT", "DELETE": + return true + default: + return false + } +} + +// RequiresIdempotencyKey reports whether the HTTP method requires an +// idempotency key for safe retry. POST and PATCH return true; all +// others return false. +func RequiresIdempotencyKey(method string) bool { + switch strings.ToUpper(method) { + case "POST", "PATCH": + return true + default: + return false + } +} diff --git a/idempotency/idempotency_test.go b/idempotency/idempotency_test.go new file mode 100644 index 0000000..77ff186 --- /dev/null +++ b/idempotency/idempotency_test.go @@ -0,0 +1,397 @@ +package idempotency + +import ( + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestValidateKey_Valid(t *testing.T) { + tests := []string{ + "abc123", + "order-12345", + "550e8400-e29b-41d4-a716-446655440000", + "a", + } + for _, key := range tests { + if err := ValidateKey(key); err != nil { + t.Errorf("ValidateKey(%q) error = %v", key, err) + } + } +} + +func TestValidateKey_Empty(t *testing.T) { + if err := ValidateKey(""); err != ErrKeyEmpty { + t.Errorf("ValidateKey(\"\") error = %v, want %v", err, ErrKeyEmpty) + } +} + +func TestValidateKey_TooLong(t *testing.T) { + key := make([]byte, KeyMaxLength+1) + for i := range key { + key[i] = 'a' + } + if err := ValidateKey(string(key)); err != ErrKeyTooLong { + t.Errorf("ValidateKey(too long) error = %v, want %v", err, ErrKeyTooLong) + } +} + +func TestValidateKey_NonPrintable(t *testing.T) { + if err := ValidateKey("abc\x00def"); err == nil { + t.Error("ValidateKey with non-printable should error") + } +} + +func TestValidateKey_WhitespaceOnly(t *testing.T) { + if err := ValidateKey(" "); err == nil { + t.Error("ValidateKey with whitespace-only should error") + } +} + +func TestFingerprintRequest_Deterministic(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if f1.Hash() != f2.Hash() { + t.Error("same request should produce same fingerprint") + } +} + +func TestFingerprintRequest_DifferentBody(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"pen"}`)) + if f1.Hash() == f2.Hash() { + t.Error("different bodies should produce different fingerprints") + } +} + +func TestFingerprintRequest_DifferentMethod(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("PUT", "/orders", []byte(`{}`)) + if f1.Hash() == f2.Hash() { + t.Error("different methods should produce different fingerprints") + } +} + +func TestFingerprintRequest_DifferentPath(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("POST", "/users", []byte(`{}`)) + if f1.Hash() == f2.Hash() { + t.Error("different paths should produce different fingerprints") + } +} + +func TestFingerprintRequest_MethodCaseInsensitive(t *testing.T) { + f1 := FingerprintRequest("post", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if f1.Hash() != f2.Hash() { + t.Error("method should be case-insensitive") + } +} + +func TestFingerprintRequest_BodyHashOnly(t *testing.T) { + f := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if f.BodyHash == "" { + t.Error("BodyHash should not be empty") + } + if f.BodyHash == `{"item":"book"}` { + t.Error("BodyHash should be a hash, not the raw body") + } +} + +func TestSafeHeaders_RemovesSensitive(t *testing.T) { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("Set-Cookie", "session=abc123") + h.Set("Authorization", "Bearer token123") + h.Set("X-Custom", "custom-value") + + safe := SafeHeaders(h) + + if safe.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be preserved") + } + if safe.Get("Set-Cookie") != "" { + t.Error("Set-Cookie should be removed") + } + if safe.Get("Authorization") != "" { + t.Error("Authorization should be removed") + } + if safe.Get("X-Custom") != "custom-value" { + t.Error("X-Custom should be preserved") + } +} + +func TestIsSafeMethod(t *testing.T) { + tests := []struct { + method string + want bool + }{ + {"GET", true}, + {"HEAD", true}, + {"PUT", true}, + {"DELETE", true}, + {"POST", false}, + {"PATCH", false}, + } + for _, tt := range tests { + if got := IsSafeMethod(tt.method); got != tt.want { + t.Errorf("IsSafeMethod(%q) = %v, want %v", tt.method, got, tt.want) + } + } +} + +func TestRequiresIdempotencyKey(t *testing.T) { + tests := []struct { + method string + want bool + }{ + {"POST", true}, + {"PATCH", true}, + {"GET", false}, + {"PUT", false}, + {"DELETE", false}, + } + for _, tt := range tests { + if got := RequiresIdempotencyKey(tt.method); got != tt.want { + t.Errorf("RequiresIdempotencyKey(%q) = %v, want %v", tt.method, got, tt.want) + } + } +} + +func TestRecord_IsExpired(t *testing.T) { + now := time.Now() + r := &Record{ + ExpiresAt: now.Add(1 * time.Hour), + } + if r.IsExpired(now) { + t.Error("record should not be expired") + } + if !r.IsExpired(now.Add(2 * time.Hour)) { + t.Error("record should be expired") + } +} + +func TestMemoryStore_Reserve_New(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if existing != nil { + t.Error("existing should be nil for new key") + } +} + +func TestMemoryStore_Reserve_Conflict(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != ErrConflict { + t.Errorf("second Reserve() error = %v, want %v", err, ErrConflict) + } + if existing == nil { + t.Error("existing should not be nil on conflict") + } +} + +func TestMemoryStore_Reserve_ReplayAvailable(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Complete("key1", 201, http.Header{"Content-Type": []string{"application/json"}}, []byte(`{"id":1}`)); err != nil { + t.Fatalf("Complete() error = %v", err) + } + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != ErrReplayAvailable { + t.Errorf("Reserve() error = %v, want %v", err, ErrReplayAvailable) + } + if existing == nil { + t.Error("existing should not be nil on replay") + } + if existing.ResponseStatus != 201 { + t.Errorf("ResponseStatus = %d, want 201", existing.ResponseStatus) + } +} + +func TestMemoryStore_Reserve_FingerprintMismatch(t *testing.T) { + s := NewMemoryStore() + fp1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if _, err := s.Reserve("key1", fp1, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + fp2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"pen"}`)) + _, err := s.Reserve("key1", fp2, 1*time.Hour) + if err != ErrFingerprintMismatch { + t.Errorf("Reserve() error = %v, want %v", err, ErrFingerprintMismatch) + } +} + +func TestMemoryStore_Complete(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + h := http.Header{"Content-Type": []string{"application/json"}} + if err := s.Complete("key1", 201, h, []byte(`{"id":1}`)); err != nil { + t.Fatalf("Complete() error = %v", err) + } + record, err := s.Get("key1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record.State != StateCompleted { + t.Errorf("State = %v, want StateCompleted", record.State) + } + if record.ResponseStatus != 201 { + t.Errorf("ResponseStatus = %d, want 201", record.ResponseStatus) + } + if string(record.ResponseBody) != `{"id":1}` { + t.Errorf("ResponseBody = %q, want %q", string(record.ResponseBody), `{"id":1}`) + } +} + +func TestMemoryStore_Fail(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Fail("key1"); err != nil { + t.Fatalf("Fail() error = %v", err) + } + record, err := s.Get("key1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record.State != StateFailed { + t.Errorf("State = %v, want StateFailed", record.State) + } +} + +func TestMemoryStore_Reserve_AfterFail(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + if err := s.Fail("key1"); err != nil { + t.Fatalf("Fail() error = %v", err) + } + // Should be able to reserve again after failure + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Errorf("Reserve after Fail() error = %v, want nil", err) + } + if existing != nil { + t.Error("existing should be nil for re-reservation after failure") + } +} + +func TestMemoryStore_Get_NotFound(t *testing.T) { + s := NewMemoryStore() + record, err := s.Get("nonexistent") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record != nil { + t.Error("record should be nil for nonexistent key") + } +} + +func TestMemoryStore_Complete_NotFound(t *testing.T) { + s := NewMemoryStore() + err := s.Complete("nonexistent", 200, nil, nil) + if err != ErrNotFound { + t.Errorf("Complete() error = %v, want %v", err, ErrNotFound) + } +} + +func TestMemoryStore_Complete_NotInProgress(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Complete("key1", 201, nil, nil); err != nil { + t.Fatalf("first Complete() error = %v", err) + } + // Second complete should fail + err := s.Complete("key1", 200, nil, nil) + if err != ErrNotInProgress { + t.Errorf("second Complete() error = %v, want %v", err, ErrNotInProgress) + } +} + +func TestMemoryStore_ExpiredRecord(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + // Reserve with very short TTL + if _, err := s.Reserve("key1", fp, 1*time.Millisecond); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + // Wait for expiration + time.Sleep(10 * time.Millisecond) + // Should be able to reserve again + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Errorf("Reserve() after expiry error = %v, want nil", err) + } + if existing != nil { + t.Error("existing should be nil for re-reservation after expiry") + } +} + +func TestMemoryStore_ConcurrentReserve(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + + var wg sync.WaitGroup + var conflicts atomic.Int32 + var successes atomic.Int32 + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := s.Reserve("concurrent-key", fp, 1*time.Hour) + if err == nil { + successes.Add(1) + } else if err == ErrConflict { + conflicts.Add(1) + } + }() + } + wg.Wait() + + if successes.Load() != 1 { + t.Errorf("expected 1 success, got %d", successes.Load()) + } + if conflicts.Load() != 9 { + t.Errorf("expected 9 conflicts, got %d", conflicts.Load()) + } +} + +func TestMemoryStore_Cleanup(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Millisecond); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if _, err := s.Reserve("key2", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + time.Sleep(10 * time.Millisecond) + s.Cleanup() + if s.Len() != 1 { + t.Errorf("Len() = %d, want 1 after cleanup", s.Len()) + } +} diff --git a/idempotency/memory_store.go b/idempotency/memory_store.go new file mode 100644 index 0000000..606d16d --- /dev/null +++ b/idempotency/memory_store.go @@ -0,0 +1,143 @@ +package idempotency + +import ( + "sync" + "time" +) + +// MemoryStore is an in-memory implementation of Store, suitable for +// testing and single-process development. It is NOT suitable for +// production use because it does not survive restarts and does not +// work across multiple process instances. +type MemoryStore struct { + mu sync.RWMutex + records map[string]*Record +} + +// NewMemoryStore creates a new in-memory idempotency store. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + records: make(map[string]*Record), + } +} + +// Reserve attempts to create an in-progress record for the given key. +func (s *MemoryStore) Reserve(key string, fingerprint RequestFingerprint, ttl time.Duration) (*Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + if existing, ok := s.records[key]; ok { + if existing.IsExpired(now) { + delete(s.records, key) + } else if existing.Fingerprint.Hash() != fingerprint.Hash() { + return existing, ErrFingerprintMismatch + } else if existing.State == StateInProgress { + return existing, ErrConflict + } else if existing.State == StateCompleted { + return existing, ErrReplayAvailable + } + // StateFailed: allow re-reservation (fall through) + } + + record := &Record{ + Key: key, + Fingerprint: fingerprint, + State: StateInProgress, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + s.records[key] = record + return nil, nil +} + +// Complete marks an in-progress record as completed. +func (s *MemoryStore) Complete(key string, status int, headers map[string][]string, body []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[key] + if !ok { + return ErrNotFound + } + if record.State != StateInProgress { + return ErrNotInProgress + } + + record.State = StateCompleted + record.ResponseStatus = status + record.ResponseHeaders = headers + record.ResponseBody = append([]byte(nil), body...) + return nil +} + +// Fail marks an in-progress record as failed. +func (s *MemoryStore) Fail(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[key] + if !ok { + return ErrNotFound + } + if record.State != StateInProgress { + return ErrNotInProgress + } + + record.State = StateFailed + return nil +} + +// Get retrieves the record for the given key. +func (s *MemoryStore) Get(key string) (*Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + record, ok := s.records[key] + if !ok { + return nil, nil + } + if record.IsExpired(time.Now()) { + return nil, nil + } + return record, nil +} + +// Cleanup removes expired records. This is a maintenance operation +// that should be called periodically. +func (s *MemoryStore) Cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for key, record := range s.records { + if record.IsExpired(now) { + delete(s.records, key) + } + } +} + +// Len returns the number of records in the store. +func (s *MemoryStore) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.records) +} + +// Additional sentinel errors for Store operations. +var ( + // ErrNotFound indicates the record was not found. + ErrNotFound = errNotFound{} + + // ErrNotInProgress indicates the record is not in-progress. + ErrNotInProgress = errNotInProgress{} +) + +type errNotFound struct{} + +func (errNotFound) Error() string { return "idempotency: record not found" } + +type errNotInProgress struct{} + +func (errNotInProgress) Error() string { return "idempotency: record not in-progress" } diff --git a/v2/idempotency/idempotency.go b/v2/idempotency/idempotency.go new file mode 100644 index 0000000..9d03581 --- /dev/null +++ b/v2/idempotency/idempotency.go @@ -0,0 +1,256 @@ +// Package idempotency provides reusable contracts for HTTP idempotency +// key handling, request fingerprinting, and replay-safe response +// capture. The package defines interfaces and helpers that allow +// applications to implement durable idempotency storage while the +// reusable library provides validation, fingerprinting, and safe +// response capture. +// +// The application retains ownership of: +// - Durable storage (Redis, database, etc.) +// - Transaction boundaries +// - Replay policy (TTL, eviction) +// - Concurrency control implementation +// +// The reusable library provides: +// - Key validation (format, length, allowed characters) +// - Request fingerprinting (deterministic hash of method, path, body) +// - Response capture contracts (status, headers, body) +// - Safe replay (never expose raw keys, never replay partial writes) +// - Conflict detection interfaces +package idempotency + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +// KeyMaxLength is the maximum allowed length for an idempotency key. +// This prevents abuse via excessively long keys. +const KeyMaxLength = 255 + +// KeyMinLength is the minimum allowed length for an idempotency key. +const KeyMinLength = 1 + +// HeaderName is the standard HTTP header for idempotency keys. +const HeaderName = "Idempotency-Key" + +// ErrInvalidKey indicates the idempotency key is malformed. +var ErrInvalidKey = errors.New("idempotency: invalid key") + +// ErrKeyTooLong indicates the idempotency key exceeds the maximum length. +var ErrKeyTooLong = errors.New("idempotency: key too long") + +// ErrKeyEmpty indicates the idempotency key is empty. +var ErrKeyEmpty = errors.New("idempotency: empty key") + +// ErrFingerprintMismatch indicates the request fingerprint does not +// match the stored fingerprint for the given idempotency key. +var ErrFingerprintMismatch = errors.New("idempotency: fingerprint mismatch") + +// ValidateKey validates an idempotency key. The key must be non-empty, +// no longer than KeyMaxLength, and contain only printable ASCII +// characters (no control characters, no whitespace-only). This +// prevents injection and abuse. +func ValidateKey(key string) error { + if key == "" { + return ErrKeyEmpty + } + if len(key) > KeyMaxLength { + return ErrKeyTooLong + } + for _, r := range key { + if r < 0x20 || r > 0x7e { + return fmt.Errorf("%w: contains non-printable character", ErrInvalidKey) + } + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("%w: whitespace-only key", ErrInvalidKey) + } + return nil +} + +// RequestFingerprint is a deterministic hash of the request method, +// path, and body. It is used to detect mismatched requests reusing the +// same idempotency key. +type RequestFingerprint struct { + // Method is the HTTP method (uppercase). + Method string + + // Path is the request path (including query string). + Path string + + // BodyHash is the SHA-256 hash of the request body (hex-encoded). + BodyHash string +} + +// Hash returns a deterministic hash of the fingerprint, suitable for +// use as a storage key or comparison value. +func (f RequestFingerprint) Hash() string { + h := sha256.New() + h.Write([]byte(f.Method)) + h.Write([]byte{0}) + h.Write([]byte(f.Path)) + h.Write([]byte{0}) + h.Write([]byte(f.BodyHash)) + return hex.EncodeToString(h.Sum(nil)) +} + +// FingerprintRequest creates a RequestFingerprint from the HTTP method, +// path, and body. The body is hashed (SHA-256) so the fingerprint does +// not retain the raw body. +func FingerprintRequest(method, path string, body []byte) RequestFingerprint { + bodyHash := sha256.Sum256(body) + return RequestFingerprint{ + Method: strings.ToUpper(method), + Path: path, + BodyHash: hex.EncodeToString(bodyHash[:]), + } +} + +// RecordState represents the lifecycle state of an idempotency record. +type RecordState int + +const ( + // StateInProgress indicates the request is being processed. + // A concurrent request with the same key should receive 409 Conflict. + StateInProgress RecordState = iota + + // StateCompleted indicates the request finished successfully and + // the stored response should be replayed. + StateCompleted + + // StateFailed indicates the request failed and should not be + // replayed (the client should retry with the same key). + StateFailed +) + +// Record represents a stored idempotency record. The application is +// responsible for persisting this; the reusable library defines the +// contract. +type Record struct { + // Key is the idempotency key (already validated). + Key string + + // Fingerprint is the request fingerprint. + Fingerprint RequestFingerprint + + // State is the current lifecycle state. + State RecordState + + // ResponseStatus is the HTTP status of the completed response. + // Valid only when State == StateCompleted. + ResponseStatus int + + // ResponseHeaders are the non-sensitive response headers to replay. + // Sensitive headers (Set-Cookie, Authorization) should be filtered + // by the application before storage. + ResponseHeaders http.Header + + // ResponseBody is the response body to replay. + // Valid only when State == StateCompleted. + ResponseBody []byte + + // CreatedAt is when the record was created. + CreatedAt time.Time + + // ExpiresAt is when the record should be evicted. + ExpiresAt time.Time +} + +// IsExpired reports whether the record has expired relative to now. +func (r *Record) IsExpired(now time.Time) bool { + return !r.ExpiresAt.IsZero() && now.After(r.ExpiresAt) +} + +// Store is the contract for idempotency record storage. The application +// implements this interface using its preferred durable storage +// (Redis, database, etc.). The reusable library does not provide a +// production Store implementation. +// +// All methods must be safe for concurrent use. The Reserve operation +// must be atomic to prevent races. +type Store interface { + // Reserve attempts to create an in-progress record for the given + // key and fingerprint. If a record already exists: + // - If it is in-progress, return the existing record and + // ErrConflict. + // - If it is completed, return the existing record and + // ErrReplayAvailable. + // - If it is expired, the implementation should evict it and + // allow the reservation. + // - If the fingerprint does not match, return + // ErrFingerprintMismatch. + Reserve(key string, fingerprint RequestFingerprint, ttl time.Duration) (existing *Record, err error) + + // Complete marks an in-progress record as completed with the + // given response. The response headers and body are stored for + // replay. Sensitive headers must be filtered by the caller before + // storage. + Complete(key string, status int, headers http.Header, body []byte) error + + // Fail marks an in-progress record as failed. The record may be + // retried by the client with the same key. + Fail(key string) error + + // Get retrieves the record for the given key. Returns nil, nil if + // the key does not exist. + Get(key string) (*Record, error) +} + +// Sentinel errors returned by Store implementations. +var ( + // ErrConflict indicates a record is in-progress for the given key. + ErrConflict = errors.New("idempotency: conflict (in-progress)") + + // ErrReplayAvailable indicates a completed record is available + // for replay. + ErrReplayAvailable = errors.New("idempotency: replay available") +) + +// SafeHeaders returns a copy of the given headers with sensitive +// headers removed. This should be called before storing response +// headers in an idempotency record to prevent replaying credentials +// or session tokens. +func SafeHeaders(h http.Header) http.Header { + safe := make(http.Header) + for k, vs := range h { + switch strings.ToLower(k) { + case "set-cookie", "authorization", "cookie", + "www-authenticate", "proxy-authenticate", + "proxy-authorization": + continue + default: + safe[k] = append([]string(nil), vs...) + } + } + return safe +} + +// IsSafeMethod reports whether the HTTP method is idempotent by +// default (GET, HEAD, PUT, DELETE). POST and PATCH require an +// idempotency key to be safely retried. +func IsSafeMethod(method string) bool { + switch strings.ToUpper(method) { + case "GET", "HEAD", "PUT", "DELETE": + return true + default: + return false + } +} + +// RequiresIdempotencyKey reports whether the HTTP method requires an +// idempotency key for safe retry. POST and PATCH return true; all +// others return false. +func RequiresIdempotencyKey(method string) bool { + switch strings.ToUpper(method) { + case "POST", "PATCH": + return true + default: + return false + } +} diff --git a/v2/idempotency/idempotency_test.go b/v2/idempotency/idempotency_test.go new file mode 100644 index 0000000..77ff186 --- /dev/null +++ b/v2/idempotency/idempotency_test.go @@ -0,0 +1,397 @@ +package idempotency + +import ( + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestValidateKey_Valid(t *testing.T) { + tests := []string{ + "abc123", + "order-12345", + "550e8400-e29b-41d4-a716-446655440000", + "a", + } + for _, key := range tests { + if err := ValidateKey(key); err != nil { + t.Errorf("ValidateKey(%q) error = %v", key, err) + } + } +} + +func TestValidateKey_Empty(t *testing.T) { + if err := ValidateKey(""); err != ErrKeyEmpty { + t.Errorf("ValidateKey(\"\") error = %v, want %v", err, ErrKeyEmpty) + } +} + +func TestValidateKey_TooLong(t *testing.T) { + key := make([]byte, KeyMaxLength+1) + for i := range key { + key[i] = 'a' + } + if err := ValidateKey(string(key)); err != ErrKeyTooLong { + t.Errorf("ValidateKey(too long) error = %v, want %v", err, ErrKeyTooLong) + } +} + +func TestValidateKey_NonPrintable(t *testing.T) { + if err := ValidateKey("abc\x00def"); err == nil { + t.Error("ValidateKey with non-printable should error") + } +} + +func TestValidateKey_WhitespaceOnly(t *testing.T) { + if err := ValidateKey(" "); err == nil { + t.Error("ValidateKey with whitespace-only should error") + } +} + +func TestFingerprintRequest_Deterministic(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if f1.Hash() != f2.Hash() { + t.Error("same request should produce same fingerprint") + } +} + +func TestFingerprintRequest_DifferentBody(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"pen"}`)) + if f1.Hash() == f2.Hash() { + t.Error("different bodies should produce different fingerprints") + } +} + +func TestFingerprintRequest_DifferentMethod(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("PUT", "/orders", []byte(`{}`)) + if f1.Hash() == f2.Hash() { + t.Error("different methods should produce different fingerprints") + } +} + +func TestFingerprintRequest_DifferentPath(t *testing.T) { + f1 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("POST", "/users", []byte(`{}`)) + if f1.Hash() == f2.Hash() { + t.Error("different paths should produce different fingerprints") + } +} + +func TestFingerprintRequest_MethodCaseInsensitive(t *testing.T) { + f1 := FingerprintRequest("post", "/orders", []byte(`{}`)) + f2 := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if f1.Hash() != f2.Hash() { + t.Error("method should be case-insensitive") + } +} + +func TestFingerprintRequest_BodyHashOnly(t *testing.T) { + f := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if f.BodyHash == "" { + t.Error("BodyHash should not be empty") + } + if f.BodyHash == `{"item":"book"}` { + t.Error("BodyHash should be a hash, not the raw body") + } +} + +func TestSafeHeaders_RemovesSensitive(t *testing.T) { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("Set-Cookie", "session=abc123") + h.Set("Authorization", "Bearer token123") + h.Set("X-Custom", "custom-value") + + safe := SafeHeaders(h) + + if safe.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be preserved") + } + if safe.Get("Set-Cookie") != "" { + t.Error("Set-Cookie should be removed") + } + if safe.Get("Authorization") != "" { + t.Error("Authorization should be removed") + } + if safe.Get("X-Custom") != "custom-value" { + t.Error("X-Custom should be preserved") + } +} + +func TestIsSafeMethod(t *testing.T) { + tests := []struct { + method string + want bool + }{ + {"GET", true}, + {"HEAD", true}, + {"PUT", true}, + {"DELETE", true}, + {"POST", false}, + {"PATCH", false}, + } + for _, tt := range tests { + if got := IsSafeMethod(tt.method); got != tt.want { + t.Errorf("IsSafeMethod(%q) = %v, want %v", tt.method, got, tt.want) + } + } +} + +func TestRequiresIdempotencyKey(t *testing.T) { + tests := []struct { + method string + want bool + }{ + {"POST", true}, + {"PATCH", true}, + {"GET", false}, + {"PUT", false}, + {"DELETE", false}, + } + for _, tt := range tests { + if got := RequiresIdempotencyKey(tt.method); got != tt.want { + t.Errorf("RequiresIdempotencyKey(%q) = %v, want %v", tt.method, got, tt.want) + } + } +} + +func TestRecord_IsExpired(t *testing.T) { + now := time.Now() + r := &Record{ + ExpiresAt: now.Add(1 * time.Hour), + } + if r.IsExpired(now) { + t.Error("record should not be expired") + } + if !r.IsExpired(now.Add(2 * time.Hour)) { + t.Error("record should be expired") + } +} + +func TestMemoryStore_Reserve_New(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if existing != nil { + t.Error("existing should be nil for new key") + } +} + +func TestMemoryStore_Reserve_Conflict(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != ErrConflict { + t.Errorf("second Reserve() error = %v, want %v", err, ErrConflict) + } + if existing == nil { + t.Error("existing should not be nil on conflict") + } +} + +func TestMemoryStore_Reserve_ReplayAvailable(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Complete("key1", 201, http.Header{"Content-Type": []string{"application/json"}}, []byte(`{"id":1}`)); err != nil { + t.Fatalf("Complete() error = %v", err) + } + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != ErrReplayAvailable { + t.Errorf("Reserve() error = %v, want %v", err, ErrReplayAvailable) + } + if existing == nil { + t.Error("existing should not be nil on replay") + } + if existing.ResponseStatus != 201 { + t.Errorf("ResponseStatus = %d, want 201", existing.ResponseStatus) + } +} + +func TestMemoryStore_Reserve_FingerprintMismatch(t *testing.T) { + s := NewMemoryStore() + fp1 := FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if _, err := s.Reserve("key1", fp1, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + fp2 := FingerprintRequest("POST", "/orders", []byte(`{"item":"pen"}`)) + _, err := s.Reserve("key1", fp2, 1*time.Hour) + if err != ErrFingerprintMismatch { + t.Errorf("Reserve() error = %v, want %v", err, ErrFingerprintMismatch) + } +} + +func TestMemoryStore_Complete(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + h := http.Header{"Content-Type": []string{"application/json"}} + if err := s.Complete("key1", 201, h, []byte(`{"id":1}`)); err != nil { + t.Fatalf("Complete() error = %v", err) + } + record, err := s.Get("key1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record.State != StateCompleted { + t.Errorf("State = %v, want StateCompleted", record.State) + } + if record.ResponseStatus != 201 { + t.Errorf("ResponseStatus = %d, want 201", record.ResponseStatus) + } + if string(record.ResponseBody) != `{"id":1}` { + t.Errorf("ResponseBody = %q, want %q", string(record.ResponseBody), `{"id":1}`) + } +} + +func TestMemoryStore_Fail(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Fail("key1"); err != nil { + t.Fatalf("Fail() error = %v", err) + } + record, err := s.Get("key1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record.State != StateFailed { + t.Errorf("State = %v, want StateFailed", record.State) + } +} + +func TestMemoryStore_Reserve_AfterFail(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("first Reserve() error = %v", err) + } + if err := s.Fail("key1"); err != nil { + t.Fatalf("Fail() error = %v", err) + } + // Should be able to reserve again after failure + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Errorf("Reserve after Fail() error = %v, want nil", err) + } + if existing != nil { + t.Error("existing should be nil for re-reservation after failure") + } +} + +func TestMemoryStore_Get_NotFound(t *testing.T) { + s := NewMemoryStore() + record, err := s.Get("nonexistent") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if record != nil { + t.Error("record should be nil for nonexistent key") + } +} + +func TestMemoryStore_Complete_NotFound(t *testing.T) { + s := NewMemoryStore() + err := s.Complete("nonexistent", 200, nil, nil) + if err != ErrNotFound { + t.Errorf("Complete() error = %v, want %v", err, ErrNotFound) + } +} + +func TestMemoryStore_Complete_NotInProgress(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if err := s.Complete("key1", 201, nil, nil); err != nil { + t.Fatalf("first Complete() error = %v", err) + } + // Second complete should fail + err := s.Complete("key1", 200, nil, nil) + if err != ErrNotInProgress { + t.Errorf("second Complete() error = %v, want %v", err, ErrNotInProgress) + } +} + +func TestMemoryStore_ExpiredRecord(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + // Reserve with very short TTL + if _, err := s.Reserve("key1", fp, 1*time.Millisecond); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + // Wait for expiration + time.Sleep(10 * time.Millisecond) + // Should be able to reserve again + existing, err := s.Reserve("key1", fp, 1*time.Hour) + if err != nil { + t.Errorf("Reserve() after expiry error = %v, want nil", err) + } + if existing != nil { + t.Error("existing should be nil for re-reservation after expiry") + } +} + +func TestMemoryStore_ConcurrentReserve(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + + var wg sync.WaitGroup + var conflicts atomic.Int32 + var successes atomic.Int32 + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := s.Reserve("concurrent-key", fp, 1*time.Hour) + if err == nil { + successes.Add(1) + } else if err == ErrConflict { + conflicts.Add(1) + } + }() + } + wg.Wait() + + if successes.Load() != 1 { + t.Errorf("expected 1 success, got %d", successes.Load()) + } + if conflicts.Load() != 9 { + t.Errorf("expected 9 conflicts, got %d", conflicts.Load()) + } +} + +func TestMemoryStore_Cleanup(t *testing.T) { + s := NewMemoryStore() + fp := FingerprintRequest("POST", "/orders", []byte(`{}`)) + if _, err := s.Reserve("key1", fp, 1*time.Millisecond); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + if _, err := s.Reserve("key2", fp, 1*time.Hour); err != nil { + t.Fatalf("Reserve() error = %v", err) + } + time.Sleep(10 * time.Millisecond) + s.Cleanup() + if s.Len() != 1 { + t.Errorf("Len() = %d, want 1 after cleanup", s.Len()) + } +} diff --git a/v2/idempotency/memory_store.go b/v2/idempotency/memory_store.go new file mode 100644 index 0000000..606d16d --- /dev/null +++ b/v2/idempotency/memory_store.go @@ -0,0 +1,143 @@ +package idempotency + +import ( + "sync" + "time" +) + +// MemoryStore is an in-memory implementation of Store, suitable for +// testing and single-process development. It is NOT suitable for +// production use because it does not survive restarts and does not +// work across multiple process instances. +type MemoryStore struct { + mu sync.RWMutex + records map[string]*Record +} + +// NewMemoryStore creates a new in-memory idempotency store. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + records: make(map[string]*Record), + } +} + +// Reserve attempts to create an in-progress record for the given key. +func (s *MemoryStore) Reserve(key string, fingerprint RequestFingerprint, ttl time.Duration) (*Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + if existing, ok := s.records[key]; ok { + if existing.IsExpired(now) { + delete(s.records, key) + } else if existing.Fingerprint.Hash() != fingerprint.Hash() { + return existing, ErrFingerprintMismatch + } else if existing.State == StateInProgress { + return existing, ErrConflict + } else if existing.State == StateCompleted { + return existing, ErrReplayAvailable + } + // StateFailed: allow re-reservation (fall through) + } + + record := &Record{ + Key: key, + Fingerprint: fingerprint, + State: StateInProgress, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + s.records[key] = record + return nil, nil +} + +// Complete marks an in-progress record as completed. +func (s *MemoryStore) Complete(key string, status int, headers map[string][]string, body []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[key] + if !ok { + return ErrNotFound + } + if record.State != StateInProgress { + return ErrNotInProgress + } + + record.State = StateCompleted + record.ResponseStatus = status + record.ResponseHeaders = headers + record.ResponseBody = append([]byte(nil), body...) + return nil +} + +// Fail marks an in-progress record as failed. +func (s *MemoryStore) Fail(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[key] + if !ok { + return ErrNotFound + } + if record.State != StateInProgress { + return ErrNotInProgress + } + + record.State = StateFailed + return nil +} + +// Get retrieves the record for the given key. +func (s *MemoryStore) Get(key string) (*Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + record, ok := s.records[key] + if !ok { + return nil, nil + } + if record.IsExpired(time.Now()) { + return nil, nil + } + return record, nil +} + +// Cleanup removes expired records. This is a maintenance operation +// that should be called periodically. +func (s *MemoryStore) Cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for key, record := range s.records { + if record.IsExpired(now) { + delete(s.records, key) + } + } +} + +// Len returns the number of records in the store. +func (s *MemoryStore) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.records) +} + +// Additional sentinel errors for Store operations. +var ( + // ErrNotFound indicates the record was not found. + ErrNotFound = errNotFound{} + + // ErrNotInProgress indicates the record is not in-progress. + ErrNotInProgress = errNotInProgress{} +) + +type errNotFound struct{} + +func (errNotFound) Error() string { return "idempotency: record not found" } + +type errNotInProgress struct{} + +func (errNotInProgress) Error() string { return "idempotency: record not in-progress" } From aee654954d1a30e147040f92b97332c0b0d05c9e Mon Sep 17 00:00:00 2001 From: Hamid Malek Mohammadi <h.malekmohammadi@stts.ir> Date: Tue, 8 Sep 2026 14:57:37 +0330 Subject: [PATCH 5/5] =?UTF-8?q?feat(conformance):=20Phase=205=20=E2=80=94?= =?UTF-8?q?=20cross-version=20conformance,=20docs,=20release=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add conformance package with shared data-only HTTP conformance vectors for Bearer challenges, Retry-After, ETags, preconditions, idempotency keys, no-body statuses, pagination links, and security sanitization. Add conformance tests in both v1 and v2 that run against the same shared vectors, verifying cross-version behavioral parity. Update MIGRATION.md to document W3C Trace Context helpers and cross-version conformance package. Run go mod tidy to reconcile v2 dependencies (otelhttp, otel/sdk, sqlite test driver promoted from indirect to direct). All tests pass with race detection in both modules; go vet clean; go build clean; go mod tidy produces no unexpected changes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MIGRATION.md | 6 + conformance/conformance_test.go | 295 +++++++++++++++++++++++++++++ conformance/vectors.go | 247 ++++++++++++++++++++++++ v2/conformance/conformance_test.go | 294 ++++++++++++++++++++++++++++ v2/conformance/vectors.go | 247 ++++++++++++++++++++++++ v2/go.mod | 15 +- v2/go.sum | 77 ++++++-- 7 files changed, 1158 insertions(+), 23 deletions(-) create mode 100644 conformance/conformance_test.go create mode 100644 conformance/vectors.go create mode 100644 v2/conformance/conformance_test.go create mode 100644 v2/conformance/vectors.go diff --git a/MIGRATION.md b/MIGRATION.md index 5889c1a..4b0b12b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -82,9 +82,15 @@ They do not change default behavior: relation link serialization. - **Retry-After** — `httpsemantics` parser/formatter for delta-seconds and HTTP-date forms; opt-in `HonorRetryAfter` in `RetryPolicy`. +- **W3C Trace Context** — `httpsemantics` helpers for inbound + extraction and outbound injection of `traceparent`/`tracestate` + headers via the globally configured OpenTelemetry propagator. - **Idempotency contracts** — `idempotency` package with store interfaces and in-memory test store. Application owns persistence and replay policy. +- **Cross-version conformance** — `conformance` package with shared + data-only HTTP conformance vectors for testing v1 and v2 against the + same RFC requirements. ## How to Upgrade diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go new file mode 100644 index 0000000..94978e7 --- /dev/null +++ b/conformance/conformance_test.go @@ -0,0 +1,295 @@ +package conformance_test + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/hmmftg/requestCore/conformance" + "github.com/hmmftg/requestCore/httpsemantics" + "github.com/hmmftg/requestCore/idempotency" + "github.com/hmmftg/requestCore/response" +) + +// TestBearerChallengeConformance verifies that v1 httpsemantics +// produces the expected Bearer challenge headers for all shared vectors. +func TestBearerChallengeConformance(t *testing.T) { + for _, tc := range conformance.BearerChallengeVectors { + t.Run(tc.Name, func(t *testing.T) { + h := http.Header{} + httpsemantics.ApplyBearerChallenge(h, httpsemantics.BearerChallenge{ + Realm: tc.Realm, + Error: httpsemantics.BearerError(tc.Error), + ErrorDescription: tc.Description, + ErrorURI: tc.URI, + Scope: tc.Scope, + }) + got := h.Get("WWW-Authenticate") + if got != tc.WantHeader { + t.Errorf("WWW-Authenticate = %q, want %q", got, tc.WantHeader) + } + }) + } +} + +// TestRetryAfterConformance verifies that v1 httpsemantics parses +// Retry-After headers correctly for all shared vectors. +func TestRetryAfterConformance(t *testing.T) { + for _, tc := range conformance.RetryAfterVectors { + t.Run(tc.Name, func(t *testing.T) { + ra, err := httpsemantics.ParseRetryAfter(tc.Header) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if tc.WantIsDate { + if !ra.IsDate { + t.Error("expected IsDate = true") + } + } else { + if ra.Delta != tc.WantDelta { + t.Errorf("Delta = %d, want %d", ra.Delta, tc.WantDelta) + } + } + }) + } +} + +// TestETagConformance verifies that v1 httpsemantics parses ETags +// correctly for all shared vectors. +func TestETagConformance(t *testing.T) { + for _, tc := range conformance.ETagVectors { + t.Run(tc.Name, func(t *testing.T) { + etag, err := httpsemantics.ParseETag(tc.Input) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if etag.Weak != tc.WantWeak { + t.Errorf("Weak = %v, want %v", etag.Weak, tc.WantWeak) + } + if etag.Value != tc.WantValue { + t.Errorf("Value = %q, want %q", etag.Value, tc.WantValue) + } + }) + } +} + +// TestPreconditionConformance verifies that v1 httpsemantics evaluates +// preconditions correctly for all shared vectors. +func TestPreconditionConformance(t *testing.T) { + for _, tc := range conformance.PreconditionVectors { + t.Run(tc.Name, func(t *testing.T) { + result := httpsemantics.EvaluatePreconditions(httpsemantics.PreconditionInput{ + IfMatch: tc.IfMatch, + IfNoneMatch: tc.IfNoneMatch, + IfModifiedSince: tc.IfModifiedSince, + IfUnmodifiedSince: tc.IfUnmodifiedSince, + ResourceETag: tc.ResourceETag, + ResourceModified: tc.ResourceModified, + IsSafeMethod: tc.IsSafeMethod, + }) + if int(result) != tc.WantResult { + t.Errorf("result = %d, want %d", result, tc.WantResult) + } + }) + } +} + +// TestIdempotencyKeyConformance verifies that v1 idempotency validates +// keys correctly for all shared vectors. +func TestIdempotencyKeyConformance(t *testing.T) { + for _, tc := range conformance.IdempotencyKeyVectors { + t.Run(tc.Name, func(t *testing.T) { + err := idempotency.ValidateKey(tc.Key) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("ValidateKey() error = %v", err) + } + }) + } +} + +// TestNoBodyStatusConformance verifies that v1 httpsemantics correctly +// identifies no-body statuses. +func TestNoBodyStatusConformance(t *testing.T) { + for _, tc := range conformance.NoBodyStatusVectors { + t.Run(tc.Name, func(t *testing.T) { + got := httpsemantics.IsNoBodyStatus(tc.Status) + if got != tc.WantNoBody { + t.Errorf("IsNoBodyStatus(%d) = %v, want %v", tc.Status, got, tc.WantNoBody) + } + }) + } +} + +// TestPaginationLinkConformance verifies that v1 httpsemantics builds +// pagination links correctly for all shared vectors. +func TestPaginationLinkConformance(t *testing.T) { + for _, tc := range conformance.PaginationLinkVectors { + t.Run(tc.Name, func(t *testing.T) { + links := httpsemantics.BuildPaginationLinks(httpsemantics.PaginationConfig{ + BaseURL: "/api/items", + Page: tc.Page, + PageSize: tc.PageSize, + TotalItems: tc.TotalItems, + }) + + rels := make(map[string]bool) + for _, l := range links { + rels[l.Rel] = true + } + + for _, want := range tc.WantRels { + if !rels[want] { + t.Errorf("missing rel %q", want) + } + } + for _, notWant := range tc.WantNoRels { + if rels[notWant] { + t.Errorf("unexpected rel %q", notWant) + } + } + }) + } +} + +// TestSecurityConformance_ProblemSanitization verifies that v1 Problem +// responses do not leak sensitive data. +func TestSecurityConformance_ProblemSanitization(t *testing.T) { + for _, tc := range conformance.SecurityVectors { + t.Run(tc.Name, func(t *testing.T) { + // Create a Problem with the sensitive data as the cause + p := response.NewProblem(http.StatusInternalServerError, "Internal Server Error"). + WithCause(errors.New(tc.SensitiveData)) + + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + + str := string(body) + if strings.Contains(str, tc.SensitiveData) { + t.Errorf("sensitive data leaked into JSON: %s", str) + } + }) + } +} + +// TestSecurityConformance_SafeHeaders verifies that v1 idempotency +// SafeHeaders removes sensitive headers. +func TestSecurityConformance_SafeHeaders(t *testing.T) { + sensitiveHeaders := []string{ + "Set-Cookie", + "Authorization", + "Cookie", + "WWW-Authenticate", + "Proxy-Authenticate", + "Proxy-Authorization", + } + + for _, hdr := range sensitiveHeaders { + t.Run(hdr, func(t *testing.T) { + h := http.Header{} + h.Set(hdr, "sensitive-value") + h.Set("Content-Type", "application/json") + + safe := idempotency.SafeHeaders(h) + if safe.Get(hdr) != "" { + t.Errorf("%s should be removed, got %q", hdr, safe.Get(hdr)) + } + if safe.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be preserved") + } + }) + } +} + +// TestTokenResponseNoStoreConformance verifies that the OAuth no-store +// headers are applied correctly. +func TestTokenResponseNoStoreConformance(t *testing.T) { + h := http.Header{} + httpsemantics.ApplyTokenResponseNoStore(h) + + if h.Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q, want %q", h.Get("Cache-Control"), "no-store") + } + if h.Get("Pragma") != "no-cache" { + t.Errorf("Pragma = %q, want %q", h.Get("Pragma"), "no-cache") + } +} + +// TestFingerprintDeterminism verifies that request fingerprinting is +// deterministic across calls. +func TestFingerprintDeterminism(t *testing.T) { + fp1 := idempotency.FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + fp2 := idempotency.FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if fp1.Hash() != fp2.Hash() { + t.Error("fingerprint should be deterministic") + } +} + +// TestFingerprintBodyNotRetained verifies that the raw body is not +// retained in the fingerprint. +func TestFingerprintBodyNotRetained(t *testing.T) { + body := []byte(`{"secret":"password123"}`) + fp := idempotency.FingerprintRequest("POST", "/orders", body) + if strings.Contains(fp.BodyHash, "password123") { + t.Error("fingerprint should not contain raw body") + } + if fp.BodyHash == string(body) { + t.Error("BodyHash should be a hash, not the raw body") + } +} + +// TestMemoryStoreRaceConformance verifies that the MemoryStore handles +// concurrent reservations safely. +func TestMemoryStoreRaceConformance(t *testing.T) { + store := idempotency.NewMemoryStore() + fp := idempotency.FingerprintRequest("POST", "/orders", []byte(`{}`)) + + var wg sync.WaitGroup + var successes atomic.Int32 + var conflicts atomic.Int32 + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := store.Reserve("race-key", fp, 1*time.Hour) + if err == nil { + successes.Add(1) + } else if err == idempotency.ErrConflict { + conflicts.Add(1) + } + }() + } + wg.Wait() + + if successes.Load() != 1 { + t.Errorf("expected 1 success, got %d", successes.Load()) + } + if conflicts.Load() != 19 { + t.Errorf("expected 19 conflicts, got %d", conflicts.Load()) + } +} diff --git a/conformance/vectors.go b/conformance/vectors.go new file mode 100644 index 0000000..02b57dd --- /dev/null +++ b/conformance/vectors.go @@ -0,0 +1,247 @@ +// Package conformance provides shared data-only HTTP conformance vectors +// for cross-version testing of requestCore v1 and v2. The vectors are +// framework-neutral and test response status, headers, tracing, and +// security behavior without depending on either module's internal types. +// +// Usage: +// +// for _, tc := range conformance.BearerChallengeVectors { +// // run tc against both v1 and v2 implementations +// } +package conformance + +import ( + "net/http" + "time" +) + +// BearerChallengeVector tests RFC 6750 WWW-Authenticate Bearer challenge +// formatting and header injection prevention. +type BearerChallengeVector struct { + Name string + Realm string + Error string + Description string + URI string + Scope string + WantHeader string +} + +// BearerChallengeVectors is the shared set of Bearer challenge test +// vectors for v1 and v2. +var BearerChallengeVectors = []BearerChallengeVector{ + { + Name: "default_realm", + WantHeader: `Bearer realm="Protected"`, + }, + { + Name: "custom_realm", + Realm: "My API", + WantHeader: `Bearer realm="My API"`, + }, + { + Name: "invalid_token", + Error: "invalid_token", + Description: "The access token expired", + URI: "https://example.com/oauth/errors", + WantHeader: `Bearer realm="Protected", error="invalid_token", error_description="The access token expired", error_uri="https://example.com/oauth/errors"`, + }, + { + Name: "insufficient_scope", + Error: "insufficient_scope", + Scope: "read write admin", + WantHeader: `Bearer realm="Protected", error="insufficient_scope", scope="read write admin"`, + }, + { + Name: "missing_token", + Error: "missing_token", + WantHeader: `Bearer realm="Protected", error="missing_token"`, + }, +} + +// RetryAfterVector tests RFC 9110 Retry-After parsing and formatting. +type RetryAfterVector struct { + Name string + Header string + WantDelta int + WantIsDate bool + WantError bool +} + +// RetryAfterVectors is the shared set of Retry-After test vectors. +var RetryAfterVectors = []RetryAfterVector{ + {Name: "delta_120", Header: "120", WantDelta: 120}, + {Name: "delta_zero", Header: "0", WantDelta: 0}, + {Name: "http_date", Header: "Tue, 21 Oct 2025 07:28:00 GMT", WantIsDate: true}, + {Name: "empty", Header: "", WantError: true}, + {Name: "negative", Header: "-1", WantError: true}, + {Name: "malformed", Header: "not a date or number", WantError: true}, +} + +// ETagVector tests RFC 9110 ETag parsing and comparison. +type ETagVector struct { + Name string + Input string + WantWeak bool + WantValue string + WantError bool +} + +// ETagVectors is the shared set of ETag test vectors. +var ETagVectors = []ETagVector{ + {Name: "strong", Input: `"abc123"`, WantValue: "abc123"}, + {Name: "weak", Input: `W/"abc123"`, WantWeak: true, WantValue: "abc123"}, + {Name: "empty", Input: "", WantError: true}, + {Name: "malformed", Input: "abc123", WantError: true}, +} + +// PreconditionVector tests RFC 9110 precondition evaluation. +type PreconditionVector struct { + Name string + IfMatch string + IfNoneMatch string + IfModifiedSince string + IfUnmodifiedSince string + ResourceETag string + ResourceModified time.Time + IsSafeMethod bool + WantResult int // 0=Proceed, 1=NotModified, 2=Failed +} + +// PreconditionVectors is the shared set of precondition test vectors. +var PreconditionVectors = []PreconditionVector{ + { + Name: "no_preconditions", + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_match_match", + IfMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_match_no_match", + IfMatch: `"abc"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + WantResult: 2, + }, + { + Name: "if_match_wildcard", + IfMatch: `*`, + ResourceETag: `"anything"`, + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_none_match_safe_304", + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + WantResult: 1, + }, + { + Name: "if_none_match_unsafe_412", + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: false, + WantResult: 2, + }, +} + +// IdempotencyKeyVector tests idempotency key validation. +type IdempotencyKeyVector struct { + Name string + Key string + WantError bool +} + +// IdempotencyKeyVectors is the shared set of idempotency key test vectors. +var IdempotencyKeyVectors = []IdempotencyKeyVector{ + {Name: "valid_simple", Key: "abc123"}, + {Name: "valid_uuid", Key: "550e8400-e29b-41d4-a716-446655440000"}, + {Name: "valid_single_char", Key: "a"}, + {Name: "empty", Key: "", WantError: true}, + {Name: "whitespace_only", Key: " ", WantError: true}, + {Name: "non_printable", Key: "abc\x00def", WantError: true}, +} + +// SecurityVector tests that sensitive data is not leaked in responses +// or telemetry. +type SecurityVector struct { + Name string + SensitiveData string + CheckInJSON bool +} + +// SecurityVectors is the shared set of security test vectors. +var SecurityVectors = []SecurityVector{ + {Name: "password", SensitiveData: "password=hunter2", CheckInJSON: true}, + {Name: "token", SensitiveData: "token=abc123", CheckInJSON: true}, + {Name: "connection_string", SensitiveData: "postgres://user:pass@host:5432/db", CheckInJSON: true}, + {Name: "internal_error", SensitiveData: "database password is secret123", CheckInJSON: true}, +} + +// NoBodyStatusVector tests that no-body statuses suppress the response body. +type NoBodyStatusVector struct { + Name string + Status int + WantNoBody bool +} + +// NoBodyStatusVectors is the shared set of no-body status test vectors. +var NoBodyStatusVectors = []NoBodyStatusVector{ + {Name: "200", Status: http.StatusOK, WantNoBody: false}, + {Name: "201", Status: http.StatusCreated, WantNoBody: false}, + {Name: "204", Status: http.StatusNoContent, WantNoBody: true}, + {Name: "304", Status: http.StatusNotModified, WantNoBody: true}, +} + +// PaginationLinkVector tests RFC 8288 pagination link generation. +type PaginationLinkVector struct { + Name string + Page int + PageSize int + TotalItems int + WantRels []string // expected rel types + WantNoRels []string // rel types that should NOT be present +} + +// PaginationLinkVectors is the shared set of pagination link test vectors. +var PaginationLinkVectors = []PaginationLinkVector{ + { + Name: "middle_page", + Page: 3, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "prev", "next", "last"}, + WantNoRels: []string{}, + }, + { + Name: "first_page", + Page: 1, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "next", "last"}, + WantNoRels: []string{"prev"}, + }, + { + Name: "last_page", + Page: 5, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "prev", "last"}, + WantNoRels: []string{"next"}, + }, + { + Name: "unknown_total", + Page: 1, + PageSize: 10, + TotalItems: 0, + WantRels: []string{"first", "next"}, + WantNoRels: []string{"last"}, + }, +} diff --git a/v2/conformance/conformance_test.go b/v2/conformance/conformance_test.go new file mode 100644 index 0000000..ac2ec9c --- /dev/null +++ b/v2/conformance/conformance_test.go @@ -0,0 +1,294 @@ +package conformance_test + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/hmmftg/requestCore/v2/conformance" + "github.com/hmmftg/requestCore/v2/httpsemantics" + "github.com/hmmftg/requestCore/v2/idempotency" + "github.com/hmmftg/requestCore/v2/response" +) + +// TestBearerChallengeConformance verifies that v2 httpsemantics +// produces the expected Bearer challenge headers for all shared vectors. +func TestBearerChallengeConformance(t *testing.T) { + for _, tc := range conformance.BearerChallengeVectors { + t.Run(tc.Name, func(t *testing.T) { + h := http.Header{} + httpsemantics.ApplyBearerChallenge(h, httpsemantics.BearerChallenge{ + Realm: tc.Realm, + Error: httpsemantics.BearerError(tc.Error), + ErrorDescription: tc.Description, + ErrorURI: tc.URI, + Scope: tc.Scope, + }) + got := h.Get("WWW-Authenticate") + if got != tc.WantHeader { + t.Errorf("WWW-Authenticate = %q, want %q", got, tc.WantHeader) + } + }) + } +} + +// TestRetryAfterConformance verifies that v2 httpsemantics parses +// Retry-After headers correctly for all shared vectors. +func TestRetryAfterConformance(t *testing.T) { + for _, tc := range conformance.RetryAfterVectors { + t.Run(tc.Name, func(t *testing.T) { + ra, err := httpsemantics.ParseRetryAfter(tc.Header) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseRetryAfter() error = %v", err) + } + if tc.WantIsDate { + if !ra.IsDate { + t.Error("expected IsDate = true") + } + } else { + if ra.Delta != tc.WantDelta { + t.Errorf("Delta = %d, want %d", ra.Delta, tc.WantDelta) + } + } + }) + } +} + +// TestETagConformance verifies that v2 httpsemantics parses ETags +// correctly for all shared vectors. +func TestETagConformance(t *testing.T) { + for _, tc := range conformance.ETagVectors { + t.Run(tc.Name, func(t *testing.T) { + etag, err := httpsemantics.ParseETag(tc.Input) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("ParseETag() error = %v", err) + } + if etag.Weak != tc.WantWeak { + t.Errorf("Weak = %v, want %v", etag.Weak, tc.WantWeak) + } + if etag.Value != tc.WantValue { + t.Errorf("Value = %q, want %q", etag.Value, tc.WantValue) + } + }) + } +} + +// TestPreconditionConformance verifies that v2 httpsemantics evaluates +// preconditions correctly for all shared vectors. +func TestPreconditionConformance(t *testing.T) { + for _, tc := range conformance.PreconditionVectors { + t.Run(tc.Name, func(t *testing.T) { + result := httpsemantics.EvaluatePreconditions(httpsemantics.PreconditionInput{ + IfMatch: tc.IfMatch, + IfNoneMatch: tc.IfNoneMatch, + IfModifiedSince: tc.IfModifiedSince, + IfUnmodifiedSince: tc.IfUnmodifiedSince, + ResourceETag: tc.ResourceETag, + ResourceModified: tc.ResourceModified, + IsSafeMethod: tc.IsSafeMethod, + }) + if int(result) != tc.WantResult { + t.Errorf("result = %d, want %d", result, tc.WantResult) + } + }) + } +} + +// TestIdempotencyKeyConformance verifies that v2 idempotency validates +// keys correctly for all shared vectors. +func TestIdempotencyKeyConformance(t *testing.T) { + for _, tc := range conformance.IdempotencyKeyVectors { + t.Run(tc.Name, func(t *testing.T) { + err := idempotency.ValidateKey(tc.Key) + if tc.WantError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Errorf("ValidateKey() error = %v", err) + } + }) + } +} + +// TestNoBodyStatusConformance verifies that v2 httpsemantics correctly +// identifies no-body statuses. +func TestNoBodyStatusConformance(t *testing.T) { + for _, tc := range conformance.NoBodyStatusVectors { + t.Run(tc.Name, func(t *testing.T) { + got := httpsemantics.IsNoBodyStatus(tc.Status) + if got != tc.WantNoBody { + t.Errorf("IsNoBodyStatus(%d) = %v, want %v", tc.Status, got, tc.WantNoBody) + } + }) + } +} + +// TestPaginationLinkConformance verifies that v2 httpsemantics builds +// pagination links correctly for all shared vectors. +func TestPaginationLinkConformance(t *testing.T) { + for _, tc := range conformance.PaginationLinkVectors { + t.Run(tc.Name, func(t *testing.T) { + links := httpsemantics.BuildPaginationLinks(httpsemantics.PaginationConfig{ + BaseURL: "/api/items", + Page: tc.Page, + PageSize: tc.PageSize, + TotalItems: tc.TotalItems, + }) + + rels := make(map[string]bool) + for _, l := range links { + rels[l.Rel] = true + } + + for _, want := range tc.WantRels { + if !rels[want] { + t.Errorf("missing rel %q", want) + } + } + for _, notWant := range tc.WantNoRels { + if rels[notWant] { + t.Errorf("unexpected rel %q", notWant) + } + } + }) + } +} + +// TestSecurityConformance_ProblemSanitization verifies that v2 Problem +// responses do not leak sensitive data. +func TestSecurityConformance_ProblemSanitization(t *testing.T) { + for _, tc := range conformance.SecurityVectors { + t.Run(tc.Name, func(t *testing.T) { + p := response.NewProblem(http.StatusInternalServerError, "Internal Server Error"). + WithCause(errors.New(tc.SensitiveData)) + + body, err := json.Marshal(p) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + + str := string(body) + if strings.Contains(str, tc.SensitiveData) { + t.Errorf("sensitive data leaked into JSON: %s", str) + } + }) + } +} + +// TestSecurityConformance_SafeHeaders verifies that v2 idempotency +// SafeHeaders removes sensitive headers. +func TestSecurityConformance_SafeHeaders(t *testing.T) { + sensitiveHeaders := []string{ + "Set-Cookie", + "Authorization", + "Cookie", + "WWW-Authenticate", + "Proxy-Authenticate", + "Proxy-Authorization", + } + + for _, hdr := range sensitiveHeaders { + t.Run(hdr, func(t *testing.T) { + h := http.Header{} + h.Set(hdr, "sensitive-value") + h.Set("Content-Type", "application/json") + + safe := idempotency.SafeHeaders(h) + if safe.Get(hdr) != "" { + t.Errorf("%s should be removed, got %q", hdr, safe.Get(hdr)) + } + if safe.Get("Content-Type") != "application/json" { + t.Error("Content-Type should be preserved") + } + }) + } +} + +// TestTokenResponseNoStoreConformance verifies that the OAuth no-store +// headers are applied correctly. +func TestTokenResponseNoStoreConformance(t *testing.T) { + h := http.Header{} + httpsemantics.ApplyTokenResponseNoStore(h) + + if h.Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q, want %q", h.Get("Cache-Control"), "no-store") + } + if h.Get("Pragma") != "no-cache" { + t.Errorf("Pragma = %q, want %q", h.Get("Pragma"), "no-cache") + } +} + +// TestFingerprintDeterminism verifies that request fingerprinting is +// deterministic across calls. +func TestFingerprintDeterminism(t *testing.T) { + fp1 := idempotency.FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + fp2 := idempotency.FingerprintRequest("POST", "/orders", []byte(`{"item":"book"}`)) + if fp1.Hash() != fp2.Hash() { + t.Error("fingerprint should be deterministic") + } +} + +// TestFingerprintBodyNotRetained verifies that the raw body is not +// retained in the fingerprint. +func TestFingerprintBodyNotRetained(t *testing.T) { + body := []byte(`{"secret":"password123"}`) + fp := idempotency.FingerprintRequest("POST", "/orders", body) + if strings.Contains(fp.BodyHash, "password123") { + t.Error("fingerprint should not contain raw body") + } + if fp.BodyHash == string(body) { + t.Error("BodyHash should be a hash, not the raw body") + } +} + +// TestMemoryStoreRaceConformance verifies that the MemoryStore handles +// concurrent reservations safely. +func TestMemoryStoreRaceConformance(t *testing.T) { + store := idempotency.NewMemoryStore() + fp := idempotency.FingerprintRequest("POST", "/orders", []byte(`{}`)) + + var wg sync.WaitGroup + var successes atomic.Int32 + var conflicts atomic.Int32 + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := store.Reserve("race-key", fp, 1*time.Hour) + if err == nil { + successes.Add(1) + } else if err == idempotency.ErrConflict { + conflicts.Add(1) + } + }() + } + wg.Wait() + + if successes.Load() != 1 { + t.Errorf("expected 1 success, got %d", successes.Load()) + } + if conflicts.Load() != 19 { + t.Errorf("expected 19 conflicts, got %d", conflicts.Load()) + } +} diff --git a/v2/conformance/vectors.go b/v2/conformance/vectors.go new file mode 100644 index 0000000..02b57dd --- /dev/null +++ b/v2/conformance/vectors.go @@ -0,0 +1,247 @@ +// Package conformance provides shared data-only HTTP conformance vectors +// for cross-version testing of requestCore v1 and v2. The vectors are +// framework-neutral and test response status, headers, tracing, and +// security behavior without depending on either module's internal types. +// +// Usage: +// +// for _, tc := range conformance.BearerChallengeVectors { +// // run tc against both v1 and v2 implementations +// } +package conformance + +import ( + "net/http" + "time" +) + +// BearerChallengeVector tests RFC 6750 WWW-Authenticate Bearer challenge +// formatting and header injection prevention. +type BearerChallengeVector struct { + Name string + Realm string + Error string + Description string + URI string + Scope string + WantHeader string +} + +// BearerChallengeVectors is the shared set of Bearer challenge test +// vectors for v1 and v2. +var BearerChallengeVectors = []BearerChallengeVector{ + { + Name: "default_realm", + WantHeader: `Bearer realm="Protected"`, + }, + { + Name: "custom_realm", + Realm: "My API", + WantHeader: `Bearer realm="My API"`, + }, + { + Name: "invalid_token", + Error: "invalid_token", + Description: "The access token expired", + URI: "https://example.com/oauth/errors", + WantHeader: `Bearer realm="Protected", error="invalid_token", error_description="The access token expired", error_uri="https://example.com/oauth/errors"`, + }, + { + Name: "insufficient_scope", + Error: "insufficient_scope", + Scope: "read write admin", + WantHeader: `Bearer realm="Protected", error="insufficient_scope", scope="read write admin"`, + }, + { + Name: "missing_token", + Error: "missing_token", + WantHeader: `Bearer realm="Protected", error="missing_token"`, + }, +} + +// RetryAfterVector tests RFC 9110 Retry-After parsing and formatting. +type RetryAfterVector struct { + Name string + Header string + WantDelta int + WantIsDate bool + WantError bool +} + +// RetryAfterVectors is the shared set of Retry-After test vectors. +var RetryAfterVectors = []RetryAfterVector{ + {Name: "delta_120", Header: "120", WantDelta: 120}, + {Name: "delta_zero", Header: "0", WantDelta: 0}, + {Name: "http_date", Header: "Tue, 21 Oct 2025 07:28:00 GMT", WantIsDate: true}, + {Name: "empty", Header: "", WantError: true}, + {Name: "negative", Header: "-1", WantError: true}, + {Name: "malformed", Header: "not a date or number", WantError: true}, +} + +// ETagVector tests RFC 9110 ETag parsing and comparison. +type ETagVector struct { + Name string + Input string + WantWeak bool + WantValue string + WantError bool +} + +// ETagVectors is the shared set of ETag test vectors. +var ETagVectors = []ETagVector{ + {Name: "strong", Input: `"abc123"`, WantValue: "abc123"}, + {Name: "weak", Input: `W/"abc123"`, WantWeak: true, WantValue: "abc123"}, + {Name: "empty", Input: "", WantError: true}, + {Name: "malformed", Input: "abc123", WantError: true}, +} + +// PreconditionVector tests RFC 9110 precondition evaluation. +type PreconditionVector struct { + Name string + IfMatch string + IfNoneMatch string + IfModifiedSince string + IfUnmodifiedSince string + ResourceETag string + ResourceModified time.Time + IsSafeMethod bool + WantResult int // 0=Proceed, 1=NotModified, 2=Failed +} + +// PreconditionVectors is the shared set of precondition test vectors. +var PreconditionVectors = []PreconditionVector{ + { + Name: "no_preconditions", + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_match_match", + IfMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_match_no_match", + IfMatch: `"abc"`, + ResourceETag: `"def"`, + IsSafeMethod: true, + WantResult: 2, + }, + { + Name: "if_match_wildcard", + IfMatch: `*`, + ResourceETag: `"anything"`, + IsSafeMethod: true, + WantResult: 0, + }, + { + Name: "if_none_match_safe_304", + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: true, + WantResult: 1, + }, + { + Name: "if_none_match_unsafe_412", + IfNoneMatch: `"abc"`, + ResourceETag: `"abc"`, + IsSafeMethod: false, + WantResult: 2, + }, +} + +// IdempotencyKeyVector tests idempotency key validation. +type IdempotencyKeyVector struct { + Name string + Key string + WantError bool +} + +// IdempotencyKeyVectors is the shared set of idempotency key test vectors. +var IdempotencyKeyVectors = []IdempotencyKeyVector{ + {Name: "valid_simple", Key: "abc123"}, + {Name: "valid_uuid", Key: "550e8400-e29b-41d4-a716-446655440000"}, + {Name: "valid_single_char", Key: "a"}, + {Name: "empty", Key: "", WantError: true}, + {Name: "whitespace_only", Key: " ", WantError: true}, + {Name: "non_printable", Key: "abc\x00def", WantError: true}, +} + +// SecurityVector tests that sensitive data is not leaked in responses +// or telemetry. +type SecurityVector struct { + Name string + SensitiveData string + CheckInJSON bool +} + +// SecurityVectors is the shared set of security test vectors. +var SecurityVectors = []SecurityVector{ + {Name: "password", SensitiveData: "password=hunter2", CheckInJSON: true}, + {Name: "token", SensitiveData: "token=abc123", CheckInJSON: true}, + {Name: "connection_string", SensitiveData: "postgres://user:pass@host:5432/db", CheckInJSON: true}, + {Name: "internal_error", SensitiveData: "database password is secret123", CheckInJSON: true}, +} + +// NoBodyStatusVector tests that no-body statuses suppress the response body. +type NoBodyStatusVector struct { + Name string + Status int + WantNoBody bool +} + +// NoBodyStatusVectors is the shared set of no-body status test vectors. +var NoBodyStatusVectors = []NoBodyStatusVector{ + {Name: "200", Status: http.StatusOK, WantNoBody: false}, + {Name: "201", Status: http.StatusCreated, WantNoBody: false}, + {Name: "204", Status: http.StatusNoContent, WantNoBody: true}, + {Name: "304", Status: http.StatusNotModified, WantNoBody: true}, +} + +// PaginationLinkVector tests RFC 8288 pagination link generation. +type PaginationLinkVector struct { + Name string + Page int + PageSize int + TotalItems int + WantRels []string // expected rel types + WantNoRels []string // rel types that should NOT be present +} + +// PaginationLinkVectors is the shared set of pagination link test vectors. +var PaginationLinkVectors = []PaginationLinkVector{ + { + Name: "middle_page", + Page: 3, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "prev", "next", "last"}, + WantNoRels: []string{}, + }, + { + Name: "first_page", + Page: 1, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "next", "last"}, + WantNoRels: []string{"prev"}, + }, + { + Name: "last_page", + Page: 5, + PageSize: 10, + TotalItems: 50, + WantRels: []string{"first", "prev", "last"}, + WantNoRels: []string{"next"}, + }, + { + Name: "unknown_total", + Page: 1, + PageSize: 10, + TotalItems: 0, + WantRels: []string{"first", "next"}, + WantNoRels: []string{"last"}, + }, +} diff --git a/v2/go.mod b/v2/go.mod index ea0d36d..bf89d8e 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -7,8 +7,12 @@ require ( github.com/go-chi/chi/v5 v5.3.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/fiber/v2 v2.52.9 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 + modernc.org/sqlite v1.58.0 + resty.dev/v3 v3.0.0-rc.3 ) require ( @@ -17,6 +21,7 @@ require ( github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -29,20 +34,22 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.65.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.41.0 // indirect @@ -51,5 +58,7 @@ require ( golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - resty.dev/v3 v3.0.0-rc.3 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) diff --git a/v2/go.sum b/v2/go.sum index d5543a0..6665760 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -8,9 +8,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= @@ -22,8 +25,6 @@ github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -43,8 +44,12 @@ github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPArei 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -60,8 +65,8 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS 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/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -69,16 +74,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -86,9 +93,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -99,38 +105,41 @@ github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Y github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -139,5 +148,33 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= resty.dev/v3 v3.0.0-rc.3 h1:k24LZ03Cb4Ue5e6O/Pfxu5TQRBBYGES6wm2wceia+Io= resty.dev/v3 v3.0.0-rc.3/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=