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..4b0b12b --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,135 @@ +# 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`. +- **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 + +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/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/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/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/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/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: +// `; 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 := `; 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 := `; 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 != `; 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 := `; rel="first", ; rel="next", ; 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(`; 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(`; rel="first", ; 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/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/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/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/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, 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= 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/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) + } +} 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" } 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) + } +}